diff --git a/io/www/src/fragment/transformers/customize.js b/io/www/src/fragment/transformers/customize.js index e8ea2ea4a..2e703b718 100644 --- a/io/www/src/fragment/transformers/customize.js +++ b/io/www/src/fragment/transformers/customize.js @@ -117,6 +117,16 @@ function countMatchedPznTokens(tags, tokens) { return n; } +/** + * Region match beats country match when resolving ties (applies to both pzn and promo variations). + * @param {{ region?: boolean, country?: boolean }|null|undefined} geo + * @returns {number} + */ +function geoMatchScore(geo) { + if (!geo) return 0; + return (geo.region ? 2 : 0) + (geo.country ? 1 : 0); +} + /** * Non-zero score means this variation applies. Higher is better: each matched request pzn token * dominates; region and country matches add smaller tie-break weight. @@ -137,7 +147,7 @@ function personalizationMatchScore(pznTags, { regionLocale, country, pzn }) { if (matchedTokens === 0 && !geo) { return 0; } - return matchedTokens * 100 + (geo?.region ? 20 : 0) + (geo?.country ? 10 : 0); + return matchedTokens * 100 + geoMatchScore(geo) * 10; } function findPersonalizationVariation(variations, customizeContext) { @@ -179,6 +189,8 @@ function promoProjectLabel(project) { } // Upper bound for probing suffixed promo variation paths (`-2`, `-3`, ...) per fragment. +// Kept in sync by hand with the same constant + `-N` suffix convention in +// studio/src/promotions/promotion-variations.js (separate runtime, no shared import). const MAX_PROMO_VARIATIONS_PER_FRAGMENT = 50; // Collects same fragment geo promos to select the best match. @@ -208,7 +220,7 @@ function selectBestPromoVariation(candidates, { regionLocale, country }) { } const geo = matchesGeo(pznTags, { regionLocale, country }); if (!geo) continue; - const score = (geo.region ? 2 : 0) + (geo.country ? 1 : 0); + const score = geoMatchScore(geo); if (score > bestScore) { bestScore = score; best = candidate; diff --git a/studio/src/common/utils/selectable-list.js b/studio/src/common/utils/selectable-list.js new file mode 100644 index 000000000..ee8d7d005 --- /dev/null +++ b/studio/src/common/utils/selectable-list.js @@ -0,0 +1,57 @@ +/** + * Shared building blocks for search + select-all + indeterminate-checkbox list UIs + * (e.g. mas-promo-variation-geos, mas-translation-languages). + */ + +/** + * Extracts the new search query value from a search input/change event. + * @param {Event} e + * @returns {string} + */ +export function handleSearchInput(e) { + return e.target.value; +} + +/** + * Filters items by a case-insensitive substring match against extracted searchable text. + * @param {Array} items + * @param {string} searchQuery + * @param {(item: any) => string} getSearchableText + * @returns {Array} + */ +export function filterBySearchQuery(items, searchQuery, getSearchableText) { + if (!searchQuery) return items; + const query = searchQuery.toLowerCase(); + return items.filter((item) => getSearchableText(item).toLowerCase().includes(query)); +} + +/** + * @param {number} selectableCount + * @param {number} selectedCount + * @returns {boolean} + */ +export function computeSelectAllChecked(selectableCount, selectedCount) { + return selectableCount > 0 && selectedCount === selectableCount; +} + +/** + * @param {number} selectableCount + * @param {number} selectedCount + * @returns {boolean} + */ +export function computeSelectAllIndeterminate(selectableCount, selectedCount) { + return selectedCount > 0 && selectedCount < selectableCount; +} + +/** + * Formats a "N selected" / "N " label, singularizing correctly at 1. + * @param {number} selectedCount + * @param {number} totalCount + * @param {string} singular + * @param {string} [plural] + * @returns {string} + */ +export function computeSelectionCountLabel(selectedCount, totalCount, singular, plural = `${singular}s`) { + if (selectedCount) return `${selectedCount} ${selectedCount === 1 ? singular : plural} selected`; + return `${totalCount} ${totalCount === 1 ? singular : plural}`; +} diff --git a/studio/src/mas-fragment-editor.js b/studio/src/mas-fragment-editor.js index eebd9baf6..916d8aa66 100644 --- a/studio/src/mas-fragment-editor.js +++ b/studio/src/mas-fragment-editor.js @@ -1833,10 +1833,10 @@ export default class MasFragmentEditor extends LitElement { `; } - displayPromoVariationGeos(clazz) { + get promoVariationGeosTemplate() { const geoCodes = this.#promoVariationGeoCodes(); if (!geoCodes.length) return nothing; - return html`
+ return html`
Geos: ${geoCodes.join(', ')}
`; } @@ -1870,7 +1870,7 @@ export default class MasFragmentEditor extends LitElement { return nothing; } if (this.isPromoVariationFragment()) { - return this.displayPromoVariationGeos('locale-variation-header'); + return this.promoVariationGeosTemplate; } if (!this.editorContextStore.isVariation(this.fragment.id)) { return nothing; diff --git a/studio/src/promotions/mas-promo-variation-geos.js b/studio/src/promotions/mas-promo-variation-geos.js index 780eca2ab..878c95160 100644 --- a/studio/src/promotions/mas-promo-variation-geos.js +++ b/studio/src/promotions/mas-promo-variation-geos.js @@ -1,5 +1,12 @@ import { LitElement, html, nothing } from 'lit'; import { styles } from './mas-promo-variation-geos.css.js'; +import { + handleSearchInput, + filterBySearchQuery, + computeSelectAllChecked, + computeSelectAllIndeterminate, + computeSelectionCountLabel, +} from '../common/utils/selectable-list.js'; class MasPromoVariationGeos extends LitElement { static styles = styles; @@ -28,23 +35,23 @@ class MasPromoVariationGeos extends LitElement { } get filteredGeos() { - if (!this.searchQuery) return this.geos; - const query = this.searchQuery.toLowerCase(); - return this.geos.filter((geo) => geo.toLowerCase().includes(query)); + return filterBySearchQuery(this.geos, this.searchQuery, (geo) => geo); + } + + handleSearch(e) { + this.searchQuery = handleSearchInput(e); } get selectAllChecked() { - return this.selectableGeos.length > 0 && this.value.length === this.selectableGeos.length; + return computeSelectAllChecked(this.selectableGeos.length, this.value.length); } get selectAllIndeterminate() { - return this.value.length > 0 && this.value.length < this.selectableGeos.length; + return computeSelectAllIndeterminate(this.selectableGeos.length, this.value.length); } get numberOfGeos() { - const count = this.value.length; - if (count) return `${count} ${count === 1 ? 'geo' : 'geos'} selected`; - return `${this.geos.length} ${this.geos.length === 1 ? 'geo' : 'geos'}`; + return computeSelectionCountLabel(this.value.length, this.geos.length, 'geo'); } get showInheritHint() { @@ -60,10 +67,6 @@ class MasPromoVariationGeos extends LitElement { return geo.split('/').pop() || geo; } - handleSearch(e) { - this.searchQuery = e.target.value; - } - selectAll(e) { e.stopPropagation(); this.emitChange(e.target.checked ? [...this.selectableGeos] : []); diff --git a/studio/src/promotions/mas-promotions-items-table.js b/studio/src/promotions/mas-promotions-items-table.js index bc6ea6b06..33303469e 100644 --- a/studio/src/promotions/mas-promotions-items-table.js +++ b/studio/src/promotions/mas-promotions-items-table.js @@ -249,7 +249,7 @@ class MasPromotionsItemsTable extends LitElement { async #syncExistingPromoVariations(items, signal) { if (signal.aborted) return; const promoTag = this.#promotionTagId; - if (!promoTag || !this.repository?.aem?.sites?.cf?.fragments?.getByPath) { + if (!promoTag || !this.repository?.aem?.sites?.cf?.fragments?.search) { if (signal.aborted) return; this.existingPromoVariationGeosByPath = new Map(); this.existingPromoVariationsByPath = new Map(); @@ -429,8 +429,13 @@ class MasPromotionsItemsTable extends LitElement { try { this.createPromoVariationLoading = true; showToast('Creating promo variation...'); - const created = await createPromoVariation(this.repository.aem, item.id, promoTag, geoTags, (store) => - this.repository.refreshFragment(store), + const created = await createPromoVariation( + this.repository.aem, + item.id, + promoTag, + geoTags, + (store) => this.repository.refreshFragment(store), + () => this.repository.loadPromotions(), ); showToast('Promo variation created', 'positive'); const previousGeos = this.existingPromoVariationGeosByPath.get(item.path) || []; diff --git a/studio/src/promotions/promotion-variations.js b/studio/src/promotions/promotion-variations.js index 4722f9cad..69e3405b5 100644 --- a/studio/src/promotions/promotion-variations.js +++ b/studio/src/promotions/promotion-variations.js @@ -15,8 +15,13 @@ import { } from './promotion-model.js'; // Max variations allowed per fragment to prevent runaway loops. +// Kept in sync by hand with the same constant + `-N` suffix convention in +// io/www/src/fragment/transformers/customize.js (separate runtime, no shared import). export const MAX_PROMO_VARIATIONS_PER_FRAGMENT = 50; +// Page size for folder search cursor (generator still walks all pages). +const VARIATION_SEARCH_PAGE_SIZE = 50; + /** * Extracts 'pznTags' values from a raw fragment payload. * @param {{ fields?: Array<{ name?: string, values?: unknown[] }> }} fragment @@ -27,8 +32,106 @@ function readPznTags(fragment) { } /** - * Sequentially probes and returns all existing promo variations for a given fragment. - * Stops at the first missing index. + * Escapes regex metacharacters so a path segment can be used as a literal match inside a RegExp. + * @param {string} value + * @returns {string} + */ +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Computes the folder to search and the leaf-matching pattern for a fragment's promo variations. + * @param {string} defaultPath + * @param {string} promoTagId + * @returns {{ parentFolder: string, leafName: string, leafPattern: RegExp }|null} + */ +function computeProbeTarget(defaultPath, promoTagId) { + const basePath = buildPromoVariationPathForTag(defaultPath, promoTagId); + if (!basePath) return null; + const parentFolder = basePath.split('/').slice(0, -1).join('/'); + const leafName = basePath.split('/').pop(); + const leafPattern = new RegExp(`^${escapeRegExp(leafName)}(?:-(\\d+))?$`); + return { parentFolder, leafName, leafPattern }; +} + +/** + * Matches a search item to a fragment's leaf pattern, returning a normalized variation or null. + * Rejects sibling leaf names (e.g., "card-2" as a separate fragment, not a variation of "card"). + * @param {Object} variation + * @param {RegExp} leafPattern + * @param {string} ownLeafName + * @param {Set} siblingLeafNames + * @returns {{ path: string, index: number, id: string, pznTags: string[], status: string, title: string, model: string, fields: Array, tags: Array }|null} + */ +function matchProbedVariation(variation, leafPattern, ownLeafName, siblingLeafNames) { + const itemLeaf = variation?.path?.split('/').pop(); + const match = itemLeaf && leafPattern.exec(itemLeaf); + if (!match || !variation?.id) return null; + if (itemLeaf !== ownLeafName && siblingLeafNames.has(itemLeaf)) return null; + const index = match[1] ? Number(match[1]) : 1; + if (index < 1 || index > MAX_PROMO_VARIATIONS_PER_FRAGMENT) return null; + return { + path: variation.path, + index, + id: variation.id, + pznTags: readPznTags(variation), + status: variation.status, + title: variation.title, + model: variation.model, + fields: variation.fields, + tags: variation.tags, + }; +} + +/** + * Probes promo variations for fragments with the same promo tag. + * Fragments sharing a parent folder are scanned in a single search (ordered by index ascending). + * @param {import('../aem/aem.js').AEM} aem + * @param {string[]} defaultPaths + * @param {string} promoTagId + * @returns {Promise>>} + */ +export async function probePromoVariationsForFragments(aem, defaultPaths, promoTagId) { + const resultsByPath = new Map((defaultPaths || []).map((defaultPath) => [defaultPath, []])); + if (!aem || !promoTagId || !defaultPaths?.length) return resultsByPath; + + const targetsByPath = new Map(); + const pathsByFolder = new Map(); + for (const defaultPath of defaultPaths) { + const target = computeProbeTarget(defaultPath, promoTagId); + if (!target) continue; + targetsByPath.set(defaultPath, target); + const pathsInFolder = pathsByFolder.get(target.parentFolder) ?? []; + pathsInFolder.push(defaultPath); + pathsByFolder.set(target.parentFolder, pathsInFolder); + } + + await processConcurrently( + [...pathsByFolder.entries()], + async ([parentFolder, pathsInFolder]) => { + const rawResults = []; + for await (const batch of aem.sites.cf.fragments.search({ path: parentFolder }, VARIATION_SEARCH_PAGE_SIZE)) { + rawResults.push(...batch); + } + const siblingLeafNames = new Set(pathsInFolder.map((path) => targetsByPath.get(path).leafName)); + for (const defaultPath of pathsInFolder) { + const { leafName, leafPattern } = targetsByPath.get(defaultPath); + const matched = rawResults + .map((variation) => matchProbedVariation(variation, leafPattern, leafName, siblingLeafNames)) + .filter(Boolean) + .sort((a, b) => a.index - b.index); + resultsByPath.set(defaultPath, matched); + } + }, + VARIATIONS_CONCURRENCY_LIMIT, + ); + + return resultsByPath; +} + +/** + * Probes all promo variations for a single fragment via paginated folder search (sorted by index). * @param {import('../aem/aem.js').AEM} aem * @param {string} defaultPath * @param {string} promoTagId @@ -36,26 +139,8 @@ function readPznTags(fragment) { */ export async function probePromoVariationsForFragment(aem, defaultPath, promoTagId) { if (!aem || !defaultPath || !promoTagId) return []; - const found = []; - for (let index = 1; index <= MAX_PROMO_VARIATIONS_PER_FRAGMENT; index += 1) { - const suffixIndex = index === 1 ? undefined : index; - const targetPath = buildPromoVariationPathForTag(defaultPath, promoTagId, suffixIndex); - if (!targetPath) break; - const variation = await getFragmentByPathOrNull(aem.sites.cf.fragments, targetPath); - if (!variation?.id) break; - found.push({ - path: targetPath, - index, - id: variation.id, - pznTags: readPznTags(variation), - status: variation.status, - title: variation.title, - model: variation.model, - fields: variation.fields, - tags: variation.tags, - }); - } - return found; + const resultsByPath = await probePromoVariationsForFragments(aem, [defaultPath], promoTagId); + return resultsByPath.get(defaultPath) || []; } /** @@ -80,16 +165,18 @@ export function findOverlappingGeoTags(existingVariations, newGeoTags) { } /** - * Finds the next available variation index, ensuring it doesn't conflict with - * other fragments attached to the same promotion project. - * @param {number} existingCount + * Finds the next available index: skips indices already used by sibling variations (gaps + * allowed) and any that would collide with another fragment in the same project. + * @param {number[]} usedIndices * @param {string} defaultPath * @param {string[]} attachedFragmentPaths * @returns {number} */ -export function getNextAvailablePromoVariationIndex(existingCount, defaultPath, attachedFragmentPaths = []) { +export function getNextAvailablePromoVariationIndex(usedIndices, defaultPath, attachedFragmentPaths = []) { + const usedSet = new Set(usedIndices); const attachedSet = new Set(attachedFragmentPaths); - for (let index = existingCount + 1; index <= MAX_PROMO_VARIATIONS_PER_FRAGMENT; index += 1) { + for (let index = 1; index <= MAX_PROMO_VARIATIONS_PER_FRAGMENT; index += 1) { + if (usedSet.has(index)) continue; if (index === 1) return index; const collisionPath = buildCandidateCollisionPath(defaultPath, index); if (!collisionPath || !attachedSet.has(collisionPath)) return index; @@ -138,7 +225,7 @@ export async function createPromoVariation(aem, sourceFragmentId, promoTagId, ge } const nextIndex = getNextAvailablePromoVariationIndex( - existingVariations.length, + existingVariations.map((variation) => variation.index), sourceFragment.path, attachedFragmentPaths, ); @@ -233,10 +320,24 @@ export async function mergePromoReferencesForDefaultFragment(aem, fragmentData, return mergePromoVariationReferences(fragmentData, discovered); } +const NUMERIC_SUFFIX_LEAF = /-\d+$/; + /** - * Resolves the source default fragment for a given promo variation path. - * Uses `attachedFragmentPaths` to prioritize the correct candidate path if the leaf has a numeric suffix, - * falling back to the first candidate that exists in AEM. + * Ranks a candidate default path: an attached path wins outright; otherwise prefer a leaf + * with no numeric suffix (a suffix is usually the variation's own leaf name, e.g. + * "my-card-2", not a coincidentally-named default). + * @param {string} candidate + * @param {Set} attachedSet + * @returns {number} + */ +function rankDefaultCandidate(candidate, attachedSet) { + if (attachedSet.has(candidate)) return 2; + return NUMERIC_SUFFIX_LEAF.test(candidate) ? 0 : 1; +} + +/** + * Resolves the source default fragment for a promo variation path: prefers an attached + * path, then the non-suffixed candidate, then the first candidate that exists in AEM. * @param {import('../aem/aem.js').AEM} aem * @param {string} promoVariationPath * @param {string} [promoVariationId] @@ -261,7 +362,9 @@ export async function resolveDefaultFragmentForPromoVariation( if (!candidates.length) return null; const attachedSet = new Set(attachedFragmentPaths); - const orderedCandidates = [...candidates].sort((a, b) => Number(attachedSet.has(b)) - Number(attachedSet.has(a))); + const orderedCandidates = [...candidates].sort( + (a, b) => rankDefaultCandidate(b, attachedSet) - rankDefaultCandidate(a, attachedSet), + ); for (const candidate of orderedCandidates) { const fragment = await getFragmentByPathOrNull(aem.sites.cf.fragments, candidate); @@ -285,22 +388,17 @@ async function collectAttachedPromoVariations(aem, promotionFragment, { onlyUnpu const attachedPaths = Array.from(new Set(promotionFragment.getFieldValues?.('fragments') || [])); if (!attachedPaths.length) return []; - const resultsPerPath = await processConcurrently( - attachedPaths, - async (parentPath) => { - const variations = await probePromoVariationsForFragment(aem, parentPath, promotionTagId); - return variations - .filter((variation) => { - if (onlyUnpublished) return variation.status !== STATUS_PUBLISHED; - if (onlyPublished) return variation.status !== STATUS_DRAFT; - return true; - }) - .map((variation) => ({ ...variation, parentPath })); - }, - VARIATIONS_CONCURRENCY_LIMIT, - ); + const variationsByPath = await probePromoVariationsForFragments(aem, attachedPaths, promotionTagId); - return resultsPerPath.flat(); + return attachedPaths.flatMap((parentPath) => + (variationsByPath.get(parentPath) || []) + .filter((variation) => { + if (onlyUnpublished) return variation.status !== STATUS_PUBLISHED; + if (onlyPublished) return variation.status !== STATUS_DRAFT; + return true; + }) + .map((variation) => ({ ...variation, parentPath })), + ); } /** @@ -324,24 +422,33 @@ export async function getPublishedAttachedPromoVariations(aem, promotionFragment } /** - * Deletes every promo variation attached to a promotion project's fragments, regardless of status. - * Live variations (PUBLISHED or MODIFIED) are unpublished first. + * Deletes every promo variation attached to a promotion project's fragments (unpublishing + * live ones first). Best-effort across all variations, then throws if any failed. * @param {import('../aem/aem.js').AEM} aem * @param {Object} promotionFragment * @returns {Promise} */ export async function deleteAttachedPromoVariations(aem, promotionFragment) { const variations = await collectAttachedPromoVariations(aem, promotionFragment); - for (const variation of variations) { - try { - if (variation.status !== STATUS_DRAFT) { - const variationWithEtag = await aem.sites.cf.fragments.getWithEtag(variation.id); - if (variationWithEtag) await aem.sites.cf.fragments.unpublish(variationWithEtag); + const failedPaths = []; + await processConcurrently( + variations, + async (variation) => { + try { + if (variation.status !== STATUS_DRAFT) { + const variationWithEtag = await aem.sites.cf.fragments.getWithEtag(variation.id); + if (variationWithEtag) await aem.sites.cf.fragments.unpublish(variationWithEtag); + } + await aem.sites.cf.fragments.forceDelete({ path: variation.path }); + } catch (error) { + console.error(`Failed to delete promo variation ${variation.path}:`, error); + failedPaths.push(variation.path); } - await aem.sites.cf.fragments.forceDelete({ path: variation.path }); - } catch (error) { - console.error(`Failed to delete promo variation ${variation.path}:`, error); - } + }, + VARIATIONS_CONCURRENCY_LIMIT, + ); + if (failedPaths.length) { + throw new UserFriendlyError(`Failed to delete ${failedPaths.length} promo variation(s): ${failedPaths.join(', ')}`); } } diff --git a/studio/src/promotions/promotions-repository.js b/studio/src/promotions/promotions-repository.js index ceac409bf..59837f93c 100644 --- a/studio/src/promotions/promotions-repository.js +++ b/studio/src/promotions/promotions-repository.js @@ -141,10 +141,11 @@ export function buildPromoVariationParentRefreshCallback(sourceFragmentId, refre * @param {string} promoTagId * @param {string[]} [geoTags] * @param {(store: import('../reactivity/fragment-store.js').FragmentStore) => Promise} [refreshFragment] + * @param {() => Promise} [loadPromotions] * @returns {Promise} */ -export async function createPromoVariation(aem, sourceFragmentId, promoTagId, geoTags = [], refreshFragment) { - const projects = readPromotionProjectsFromStore(); +export async function createPromoVariation(aem, sourceFragmentId, promoTagId, geoTags = [], refreshFragment, loadPromotions) { + const projects = await getPromotionProjectsForProbe(loadPromotions); const attachedFragmentPaths = getAttachedFragmentPathsForTag(projects, promoTagId); const onCreated = refreshFragment ? buildPromoVariationParentRefreshCallback(sourceFragmentId, refreshFragment) : undefined; const createdFragment = await promotionVariations.createPromoVariation( diff --git a/studio/src/translation/mas-translation-languages.js b/studio/src/translation/mas-translation-languages.js index 02d896f33..c68bd8461 100644 --- a/studio/src/translation/mas-translation-languages.js +++ b/studio/src/translation/mas-translation-languages.js @@ -3,6 +3,13 @@ import { styles } from './mas-translation-languages.css.js'; import Store from '../store.js'; import { getDefaultLocales, getSurfaceLocales, getLocaleCode, REGION_GROUPS } from '../locales.js'; import ReactiveController from '../reactivity/reactive-controller.js'; +import { + handleSearchInput, + filterBySearchQuery, + computeSelectAllChecked, + computeSelectAllIndeterminate, + computeSelectionCountLabel, +} from '../common/utils/selectable-list.js'; class MasTranslationLanguages extends LitElement { static styles = styles; @@ -38,23 +45,23 @@ class MasTranslationLanguages extends LitElement { } get filteredLocales() { - if (!this.searchQuery) return this.localesArray; - const q = this.searchQuery.toLowerCase(); - return this.localesArray.filter((item) => item.locale.toLowerCase().includes(q)); + return filterBySearchQuery(this.localesArray, this.searchQuery, (item) => item.locale); + } + + handleSearch(e) { + this.searchQuery = handleSearchInput(e); } get selectAllChecked() { - return this.selectedLocales.length === this.localesArray.length; + return computeSelectAllChecked(this.localesArray.length, this.selectedLocales.length); } get selectAllIndeterminate() { - return this.selectedLocales.length > 0 && this.selectedLocales.length < this.localesArray.length; + return computeSelectAllIndeterminate(this.localesArray.length, this.selectedLocales.length); } get numberOfLocales() { - const count = this.selectedLocales.length; - if (count) return `${count} ${count === 1 ? 'language' : 'languages'} selected`; - return `${this.localesArray.length} languages`; + return computeSelectionCountLabel(this.selectedLocales.length, this.localesArray.length, 'language'); } get groupedLocales() { @@ -70,10 +77,6 @@ class MasTranslationLanguages extends LitElement { return groups; } - handleSearch(e) { - this.searchQuery = e.target.value; - } - selectAll(e) { const next = e.target.checked ? this.localesArray.map((item) => item.locale) : []; this.targetStore.targetLocales.set(next); diff --git a/studio/test/common/utils/selectable-list.test.js b/studio/test/common/utils/selectable-list.test.js new file mode 100644 index 000000000..ba4a557dd --- /dev/null +++ b/studio/test/common/utils/selectable-list.test.js @@ -0,0 +1,87 @@ +import { expect } from '@esm-bundle/chai'; +import { + handleSearchInput, + filterBySearchQuery, + computeSelectAllChecked, + computeSelectAllIndeterminate, + computeSelectionCountLabel, +} from '../../../src/common/utils/selectable-list.js'; + +describe('selectable-list', () => { + describe('handleSearchInput', () => { + it('extracts the value from the event target', () => { + expect(handleSearchInput({ target: { value: 'fr' } })).to.equal('fr'); + }); + }); + + describe('filterBySearchQuery', () => { + it('returns all items unfiltered when searchQuery is empty', () => { + const items = ['a', 'b', 'c']; + expect(filterBySearchQuery(items, '', (item) => item)).to.deep.equal(items); + }); + + it('filters items case-insensitively by the extracted searchable text', () => { + const items = ['mas:pzn/country/fr', 'mas:pzn/country/ae']; + expect(filterBySearchQuery(items, 'FR', (item) => item)).to.deep.equal(['mas:pzn/country/fr']); + }); + + it('applies getSearchableText per item rather than filtering the raw item', () => { + const items = [{ locale: 'en_US' }, { locale: 'fr_FR' }]; + expect(filterBySearchQuery(items, 'en', (item) => item.locale)).to.deep.equal([{ locale: 'en_US' }]); + }); + }); + + describe('computeSelectAllChecked', () => { + it('returns false when there are no selectable items', () => { + expect(computeSelectAllChecked(0, 0)).to.be.false; + }); + + it('returns true when every selectable item is selected', () => { + expect(computeSelectAllChecked(3, 3)).to.be.true; + }); + + it('returns false when only some items are selected', () => { + expect(computeSelectAllChecked(3, 2)).to.be.false; + }); + }); + + describe('computeSelectAllIndeterminate', () => { + it('returns false when nothing is selected', () => { + expect(computeSelectAllIndeterminate(3, 0)).to.be.false; + }); + + it('returns true when some but not all items are selected', () => { + expect(computeSelectAllIndeterminate(3, 1)).to.be.true; + }); + + it('returns false when every item is selected', () => { + expect(computeSelectAllIndeterminate(3, 3)).to.be.false; + }); + }); + + describe('computeSelectionCountLabel', () => { + it('shows the total with the plural noun when nothing is selected', () => { + expect(computeSelectionCountLabel(0, 3, 'geo')).to.equal('3 geos'); + }); + + it('singularizes the total noun when there is exactly one item', () => { + expect(computeSelectionCountLabel(0, 1, 'geo')).to.equal('1 geo'); + }); + + it('shows "N selected" when items are selected', () => { + expect(computeSelectionCountLabel(2, 3, 'geo')).to.equal('2 geos selected'); + }); + + it('singularizes the selected noun when exactly one is selected', () => { + expect(computeSelectionCountLabel(1, 3, 'geo')).to.equal('1 geo selected'); + }); + + it('derives the default plural by appending "s" when none is provided', () => { + expect(computeSelectionCountLabel(0, 3, 'language')).to.equal('3 languages'); + }); + + it('uses an explicit plural noun when the default "s" suffix would be wrong', () => { + expect(computeSelectionCountLabel(2, 3, 'country', 'countries')).to.equal('2 countries selected'); + }); + }); +}); diff --git a/studio/test/helpers/aem-tag-fetch.js b/studio/test/helpers/aem-tag-fetch.js index 662bc19ea..2e3de5bc1 100644 --- a/studio/test/helpers/aem-tag-fetch.js +++ b/studio/test/helpers/aem-tag-fetch.js @@ -29,3 +29,16 @@ export const stubAemTagQueryFetch = (sandbox, body = emptyTagQuery) => { return asTagQueryResponse(body); }); }; + +/** + * Stubs aem.sites.cf.fragments.search as an async generator keyed by folder path. + * Tests use this instead of getByPath because probePromoVariationsForFragment resolves variations via a single folder search rather than individual paths. + * @param {{ stub: Function }} stubber - a sinon sandbox instance, or the sinon module itself + * @param {Object} [itemsByFolder] - search results keyed by the requested folder path + * @returns {import('sinon').SinonStub} + */ +export function makeSearchStub(stubber, itemsByFolder = {}) { + return stubber.stub().callsFake(async function* (query) { + yield itemsByFolder[query?.path] || []; + }); +} diff --git a/studio/test/mas-fragment-editor.test.js b/studio/test/mas-fragment-editor.test.js index 3142e5968..c90a09a8b 100644 --- a/studio/test/mas-fragment-editor.test.js +++ b/studio/test/mas-fragment-editor.test.js @@ -16,6 +16,7 @@ import router from '../src/router.js'; import Events from '../src/events.js'; import { extractLocaleFromPath } from '../src/utils.js'; import { nothing, render } from 'lit'; +import { makeSearchStub } from './helpers/aem-tag-fetch.js'; describe('MasFragmentEditor', () => { let sandbox; @@ -1341,10 +1342,12 @@ describe('MasFragmentEditor', () => { const originalFragmentId = Store.fragmentEditor.fragmentId.value; Store.fragmentEditor.fragmentId.value = fragment.id; - const getByPath = sandbox.stub().callsFake((path) => { - if (path === promoPath) return Promise.resolve({ id: 'promo-var-id', path: promoPath, fields: [] }); - if (path === siblingPath) return Promise.resolve({ id: 'sibling-id', path: siblingPath, fields: [] }); - return Promise.resolve(null); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/back-to-school'; + const search = makeSearchStub(sandbox, { + [promoFolder]: [ + { id: 'promo-var-id', path: promoPath, fields: [] }, + { id: 'sibling-id', path: siblingPath, fields: [] }, + ], }); const mockRepo = { aem: { @@ -1355,7 +1358,7 @@ describe('MasFragmentEditor', () => { id: 'promo-project-id', fields: [{ name: 'geos', values: ['mas:locale/de_AT', 'mas:locale/en_NG'] }], }), - getByPath, + search, }, }, }, diff --git a/studio/test/mas-repository.test.js b/studio/test/mas-repository.test.js index 334249f3e..6fb24a627 100644 --- a/studio/test/mas-repository.test.js +++ b/studio/test/mas-repository.test.js @@ -6,6 +6,7 @@ import { MasRepository } from '../src/mas-repository.js'; import { ROOT_PATH, SURFACES, PAGE_NAMES, EDITABLE_FRAGMENT_MODEL_IDS, COLLECTION_MODEL_PATH } from '../src/constants.js'; import Events from '../src/events.js'; import Store from '../src/store.js'; +import { makeSearchStub } from './helpers/aem-tag-fetch.js'; const mockFragmentCache = { get: () => null, @@ -1496,12 +1497,10 @@ describe('MasRepository dictionary helpers', () => { repository.loadPromotions = sandbox.stub().resolves(); const getByIdStub = sandbox.stub().resolves(mockFragment); - const getByPathStub = sandbox.stub().callsFake((path) => { - if (path === promoVariationPath) return Promise.resolve(promoVariationFragment); - return Promise.resolve(null); - }); + const promoFolder = `${ROOT_PATH}/acom/en_US/promotions/summer-sale`; + const searchStub = makeSearchStub(sandbox, { [promoFolder]: [promoVariationFragment] }); repository.aem = createAemMock({ - fragments: { getById: getByIdStub, getByPath: getByPathStub, search: sandbox.stub() }, + fragments: { getById: getByIdStub, search: searchStub }, }); const mockPromoProject = { @@ -1516,8 +1515,8 @@ describe('MasRepository dictionary helpers', () => { const { mockDataStore, restore } = await setupVariationUuidSearch(); try { await repository.searchFragments(); - expect(getByPathStub.calledWith(promoVariationPath), 'should probe the deterministic promo variation path') - .to.be.true; + expect(searchStub.calledWith({ path: promoFolder }, 50), 'should probe the promo variation folder').to.be + .true; const fragmentInStore = mockDataStore.set.lastCall?.args[0]?.[0]?.get?.(); const promoRefs = (fragmentInStore?.references || []).filter((r) => r.path === promoVariationPath); expect(promoRefs.length, 'promo variation reference should be merged into fragment').to.equal(1); diff --git a/studio/test/promotions/mas-promotions-editor.test.js b/studio/test/promotions/mas-promotions-editor.test.js index dd25f04eb..7a9bb2b57 100644 --- a/studio/test/promotions/mas-promotions-editor.test.js +++ b/studio/test/promotions/mas-promotions-editor.test.js @@ -8,6 +8,7 @@ import { Promotion } from '../../src/aem/promotion.js'; import { CARD_MODEL_PATH, EVENT_OST_OFFER_SELECT, PAGE_NAMES, TABLE_TYPE, TAG_PROMOTION_PREFIX } from '../../src/constants.js'; import { normalizeKey, UserFriendlyError } from '../../src/utils.js'; import { buildPromotionTagPath, serializePromotionSurfacesForAem } from '../../src/promotions/promotion-editor-utils.js'; +import { makeSearchStub as makeSharedSearchStub } from '../helpers/aem-tag-fetch.js'; function makeFragmentData(overrides = {}) { return { @@ -119,6 +120,10 @@ describe('MasPromotionsEditor', () => { return el; } + function makeSearchStub(itemsByFolder = {}) { + return makeSharedSearchStub(sandbox, itemsByFolder); + } + function makeRepo(overrides = {}) { return { getPromotionsPath: () => '/content/dam/mas/promotions', @@ -138,7 +143,7 @@ describe('MasPromotionsEditor', () => { publish: sandbox.stub().resolves(), publishFragments: sandbox.stub().resolves(), getWithEtag: sandbox.stub(), - getByPath: sandbox.stub().resolves(null), + search: makeSearchStub(), }, }, }, @@ -1218,7 +1223,7 @@ describe('MasPromotionsEditor', () => { getById: sandbox.stub().resolves(null), publish, publishFragments: sandbox.stub().resolves(), - getByPath: sandbox.stub().resolves(null), + search: makeSearchStub(), }, }, }, @@ -1341,12 +1346,9 @@ describe('MasPromotionsEditor', () => { ], }); Store.promotions.inEdit.set(new FragmentStore(promotion)); - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoVarPath).resolves({ - id: 'promo-var-id', - path: promoVarPath, - status: 'PUBLISHED', - title: 'Published variation', + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/code-test'; + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoVarPath, status: 'PUBLISHED', title: 'Published variation' }], }); const unpublish = sandbox.stub().resolves(); const { el, repo } = await mountEditorWithRepo({ @@ -1360,7 +1362,7 @@ describe('MasPromotionsEditor', () => { fragments: { getById: sandbox.stub().resolves(null), getWithEtag: sandbox.stub(), - getByPath, + search, unpublish, }, }, @@ -1374,7 +1376,7 @@ describe('MasPromotionsEditor', () => { await new Promise((resolve) => setTimeout(resolve, 0)); await el.updateComplete; - expect(repo.aem.sites.cf.fragments.getByPath.calledWith(promoVarPath)).to.be.true; + expect(repo.aem.sites.cf.fragments.search.calledWith({ path: promoFolder }, 50)).to.be.true; expect(repo.aem.sites.cf.fragments.unpublish.called).to.be.false; }); }); @@ -1403,12 +1405,9 @@ describe('MasPromotionsEditor', () => { ], }); Store.promotions.inEdit.set(new FragmentStore(promotion)); - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoVarPath).resolves({ - id: 'promo-var-id', - path: promoVarPath, - status: 'DRAFT', - title: 'Unpublished variation', + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/code-test'; + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoVarPath, status: 'DRAFT', title: 'Unpublished variation' }], }); const { el, repo } = await mountEditorWithRepo({ aem: { @@ -1423,7 +1422,7 @@ describe('MasPromotionsEditor', () => { publish: sandbox.stub().resolves(), publishFragments: sandbox.stub().resolves(), getWithEtag: sandbox.stub(), - getByPath, + search, }, }, }, @@ -1436,7 +1435,7 @@ describe('MasPromotionsEditor', () => { await new Promise((resolve) => setTimeout(resolve, 0)); await el.updateComplete; - expect(repo.aem.sites.cf.fragments.getByPath.calledWith(promoVarPath)).to.be.true; + expect(repo.aem.sites.cf.fragments.search.calledWith({ path: promoFolder }, 50)).to.be.true; expect(repo.aem.sites.cf.fragments.publish.called).to.be.false; expect(repo.aem.sites.cf.fragments.publishFragments.called).to.be.false; }); @@ -1573,11 +1572,11 @@ describe('MasPromotionsEditor', () => { }), ), ); - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoVarPath).resolves({ id: 'promo-var-id', path: promoVarPath, status: 'DRAFT' }); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/code-test'; + const search = makeSearchStub({ [promoFolder]: [{ id: 'promo-var-id', path: promoVarPath, status: 'DRAFT' }] }); const { el } = await mountEditorWithRepo({ deleteFragment: sandbox.stub().resolves(true), - aem: { sites: { cf: { fragments: { getByPath, getById: sandbox.stub().resolves(null) } } } }, + aem: { sites: { cf: { fragments: { search, getById: sandbox.stub().resolves(null) } } } }, }); await el.updateComplete; clickPromotionQuickAction(el, 'Delete'); @@ -1621,14 +1620,14 @@ describe('MasPromotionsEditor', () => { }), ), ); - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoVarPath).resolves({ id: 'promo-var-id', path: promoVarPath, status: 'DRAFT' }); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/code-test'; + const search = makeSearchStub({ [promoFolder]: [{ id: 'promo-var-id', path: promoVarPath, status: 'DRAFT' }] }); const forceDelete = sandbox.stub().resolves(); const deleteFragment = sandbox.stub().resolves(true); const { el, repo } = await mountEditorWithRepo({ deleteFragment, aem: { - sites: { cf: { fragments: { getByPath, getById: sandbox.stub().resolves(null), forceDelete } } }, + sites: { cf: { fragments: { search, getById: sandbox.stub().resolves(null), forceDelete } } }, }, }); await el.updateComplete; @@ -1867,25 +1866,27 @@ describe('MasPromotionsEditor', () => { it('copies a nice title and deep link for all attached variations, including published ones', async () => { const { FragmentStore } = await import('../../src/reactivity/fragment-store.js'); Store.promotions.inEdit.set(new FragmentStore(makePromotion({ id: 'promo-id', title: 'Campaign' }))); - const variationPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const variationPath = `${promoFolder}/my-card`; const { el } = await mountEditorWithRepo({ aem: { sites: { cf: { fragments: { getById: sandbox.stub().resolves(null), - getByPath: sandbox - .stub() - .withArgs(variationPath) - .resolves({ - id: 'variation-id', - path: variationPath, - status: 'PUBLISHED', - title: 'Variation', - model: { path: CARD_MODEL_PATH }, - tags: [], - fields: [], - }), + search: makeSearchStub({ + [promoFolder]: [ + { + id: 'variation-id', + path: variationPath, + status: 'PUBLISHED', + title: 'Variation', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [], + }, + ], + }), }, }, }, @@ -1935,25 +1936,27 @@ describe('MasPromotionsEditor', () => { it('shows a negative toast when the clipboard write fails', async () => { const { FragmentStore } = await import('../../src/reactivity/fragment-store.js'); Store.promotions.inEdit.set(new FragmentStore(makePromotion({ id: 'promo-id-fail', title: 'Campaign' }))); - const variationPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const variationPath = `${promoFolder}/my-card`; const { el } = await mountEditorWithRepo({ aem: { sites: { cf: { fragments: { getById: sandbox.stub().resolves(null), - getByPath: sandbox - .stub() - .withArgs(variationPath) - .resolves({ - id: 'variation-id', - path: variationPath, - status: 'DRAFT', - title: 'Variation', - model: { path: CARD_MODEL_PATH }, - tags: [], - fields: [], - }), + search: makeSearchStub({ + [promoFolder]: [ + { + id: 'variation-id', + path: variationPath, + status: 'DRAFT', + title: 'Variation', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [], + }, + ], + }), }, }, }, @@ -1978,7 +1981,8 @@ describe('MasPromotionsEditor', () => { it('shows a negative toast instead of crashing when a variation is missing tags/fields', async () => { const { FragmentStore } = await import('../../src/reactivity/fragment-store.js'); Store.promotions.inEdit.set(new FragmentStore(makePromotion({ id: 'promo-id-malformed', title: 'Campaign' }))); - const variationPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const variationPath = `${promoFolder}/my-card`; const { el } = await mountEditorWithRepo({ aem: { sites: { @@ -1987,16 +1991,17 @@ describe('MasPromotionsEditor', () => { getById: sandbox.stub().resolves(null), // Simulates an AEM response missing `tags`/`fields`, which used to throw // inside Fragment.getTagTitle() outside the try/catch. - getByPath: sandbox - .stub() - .withArgs(variationPath) - .resolves({ - id: 'variation-id', - path: variationPath, - status: 'PUBLISHED', - title: 'Variation', - model: { path: CARD_MODEL_PATH }, - }), + search: makeSearchStub({ + [promoFolder]: [ + { + id: 'variation-id', + path: variationPath, + status: 'PUBLISHED', + title: 'Variation', + model: { path: CARD_MODEL_PATH }, + }, + ], + }), }, }, }, @@ -2021,29 +2026,33 @@ describe('MasPromotionsEditor', () => { it('copies only the supported variations and reports a partial count when some model paths are unsupported', async () => { const { FragmentStore } = await import('../../src/reactivity/fragment-store.js'); Store.promotions.inEdit.set(new FragmentStore(makePromotion({ id: 'promo-id-partial', title: 'Campaign' }))); - const supportedPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const unsupportedPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/other-card'; - const getByPath = sandbox.stub(); - getByPath.withArgs(supportedPath).resolves({ - id: 'variation-id', - path: supportedPath, - status: 'PUBLISHED', - title: 'Variation', - model: { path: CARD_MODEL_PATH }, - tags: [], - fields: [], - }); - getByPath.withArgs(unsupportedPath).resolves({ - id: 'variation-id-2', - path: unsupportedPath, - status: 'PUBLISHED', - title: 'Variation 2', - model: { path: '/conf/mas/settings/dam/cfm/models/unknown' }, - tags: [], - fields: [], + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const supportedPath = `${promoFolder}/my-card`; + const unsupportedPath = `${promoFolder}/other-card`; + const search = makeSearchStub({ + [promoFolder]: [ + { + id: 'variation-id', + path: supportedPath, + status: 'PUBLISHED', + title: 'Variation', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [], + }, + { + id: 'variation-id-2', + path: unsupportedPath, + status: 'PUBLISHED', + title: 'Variation 2', + model: { path: '/conf/mas/settings/dam/cfm/models/unknown' }, + tags: [], + fields: [], + }, + ], }); const { el } = await mountEditorWithRepo({ - aem: { sites: { cf: { fragments: { getById: sandbox.stub().resolves(null), getByPath } } } }, + aem: { sites: { cf: { fragments: { getById: sandbox.stub().resolves(null), search } } } }, }); el.fragmentStore.updateField('tags', ['mas:promotion/black-friday']); el.fragmentStore.updateField('fragments', [ @@ -2074,25 +2083,27 @@ describe('MasPromotionsEditor', () => { it('shows a distinct info toast when variations exist but none have a copyable model path', async () => { const { FragmentStore } = await import('../../src/reactivity/fragment-store.js'); Store.promotions.inEdit.set(new FragmentStore(makePromotion({ id: 'promo-id-unsupported', title: 'Campaign' }))); - const variationPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const variationPath = `${promoFolder}/my-card`; const { el } = await mountEditorWithRepo({ aem: { sites: { cf: { fragments: { getById: sandbox.stub().resolves(null), - getByPath: sandbox - .stub() - .withArgs(variationPath) - .resolves({ - id: 'variation-id', - path: variationPath, - status: 'PUBLISHED', - title: 'Variation', - model: { path: '/conf/mas/settings/dam/cfm/models/unknown' }, - tags: [], - fields: [], - }), + search: makeSearchStub({ + [promoFolder]: [ + { + id: 'variation-id', + path: variationPath, + status: 'PUBLISHED', + title: 'Variation', + model: { path: '/conf/mas/settings/dam/cfm/models/unknown' }, + tags: [], + fields: [], + }, + ], + }), }, }, }, diff --git a/studio/test/promotions/mas-promotions-items-table.test.js b/studio/test/promotions/mas-promotions-items-table.test.js index 0ed843514..4cda074f0 100644 --- a/studio/test/promotions/mas-promotions-items-table.test.js +++ b/studio/test/promotions/mas-promotions-items-table.test.js @@ -11,7 +11,7 @@ import Events from '../../src/events.js'; import '../../src/swc.js'; import MasPromotionsItemsTable from '../../src/promotions/mas-promotions-items-table.js'; import { buildPromotionOfferRecord } from '../../src/promotions/promotion-editor-utils.js'; -import { buildPromoVariationPathForTag } from '../../src/promotions/promotion-model.js'; +import { makeSearchStub as makeSharedSearchStub } from '../helpers/aem-tag-fetch.js'; describe('MasPromotionsItemsTable', () => { let sandbox; @@ -949,6 +949,10 @@ describe('MasPromotionsItemsTable', () => { Store.promotions.inEdit.set(new FragmentStore(promotion)); }; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + + const makeSearchStub = (itemsByFolder = {}) => makeSharedSearchStub(sandbox, itemsByFolder); + const createPromoVariationAem = (overrides = {}) => { const parentFragment = { id: 'card-promo-id', @@ -964,7 +968,7 @@ describe('MasPromotionsItemsTable', () => { cf: { fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath: sandbox.stub().resolves(null), + search: makeSearchStub(), ensureFolderExists: sandbox.stub().resolves(), pollCreatedFragment: sandbox.stub().resolves(createdFragment), ...overrides.fragments, @@ -1011,6 +1015,7 @@ describe('MasPromotionsItemsTable', () => { const el = await fixture(html``); sandbox.stub(el, 'repository').get(() => ({ refreshFragment: sandbox.stub().resolves(), + loadPromotions: sandbox.stub().resolves(), aem, })); el.viewOnlyFragments = [cardFragment]; @@ -1106,15 +1111,15 @@ describe('MasPromotionsItemsTable', () => { sites: { cf: { fragments: { - getByPath: sandbox.stub().callsFake((path) => - path === promoVariationPath - ? Promise.resolve({ - id: 'existing-var', - path: promoVariationPath, - fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], - }) - : Promise.resolve(null), - ), + search: makeSearchStub({ + [promoFolder]: [ + { + id: 'existing-var', + path: promoVariationPath, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + }, + ], + }), }, }, }, @@ -1146,13 +1151,9 @@ describe('MasPromotionsItemsTable', () => { sites: { cf: { fragments: { - getByPath: sandbox - .stub() - .callsFake((path) => - path === promoVariationPath - ? Promise.resolve({ id: 'existing-var', path: promoVariationPath, fields: [] }) - : Promise.resolve(null), - ), + search: makeSearchStub({ + [promoFolder]: [{ id: 'existing-var', path: promoVariationPath, fields: [] }], + }), }, }, }, @@ -1185,13 +1186,9 @@ describe('MasPromotionsItemsTable', () => { sites: { cf: { fragments: { - getByPath: sandbox - .stub() - .callsFake((path) => - path === promoVariationPath - ? Promise.resolve({ id: 'existing-var', path: promoVariationPath, fields: [] }) - : Promise.resolve(null), - ), + search: makeSearchStub({ + [promoFolder]: [{ id: 'existing-var', path: promoVariationPath, fields: [] }], + }), }, }, }, @@ -1314,20 +1311,21 @@ describe('MasPromotionsItemsTable', () => { const aem = createPromoVariationAem({ fragments: { - getByPath: sandbox.stub().callsFake((path) => - path === promoVariationPath - ? Promise.resolve({ - id: 'existing-var', - path: promoVariationPath, - fields: [{ name: 'pznTags', values: ['mas:locale/de_AT'] }], - }) - : Promise.resolve(null), - ), + search: makeSearchStub({ + [promoFolder]: [ + { + id: 'existing-var', + path: promoVariationPath, + fields: [{ name: 'pznTags', values: ['mas:locale/de_AT'] }], + }, + ], + }), }, }); const el = await fixture(html``); sandbox.stub(el, 'repository').get(() => ({ refreshFragment: sandbox.stub().resolves(), + loadPromotions: sandbox.stub().resolves(), aem, })); el.viewOnlyFragments = [cardFragment]; @@ -1359,15 +1357,15 @@ describe('MasPromotionsItemsTable', () => { const aem = createPromoVariationAem({ fragments: { - getByPath: sandbox.stub().callsFake((path) => - path === promoVariationPath - ? Promise.resolve({ - id: 'existing-var', - path: promoVariationPath, - fields: [{ name: 'title', values: ['Promo Card'] }], - }) - : Promise.resolve(null), - ), + search: makeSearchStub({ + [promoFolder]: [ + { + id: 'existing-var', + path: promoVariationPath, + fields: [{ name: 'title', values: ['Promo Card'] }], + }, + ], + }), }, }); const el = await fixture(html``); @@ -1395,15 +1393,15 @@ describe('MasPromotionsItemsTable', () => { const aem = createPromoVariationAem({ fragments: { - getByPath: sandbox.stub().callsFake((path) => - path === promoVariationPath - ? Promise.resolve({ - id: 'existing-var', - path: promoVariationPath, - fields: [{ name: 'title', values: ['Promo Card'] }], - }) - : Promise.resolve(null), - ), + search: makeSearchStub({ + [promoFolder]: [ + { + id: 'existing-var', + path: promoVariationPath, + fields: [{ name: 'title', values: ['Promo Card'] }], + }, + ], + }), }, }); const el = await fixture(html``); @@ -1492,7 +1490,9 @@ describe('MasPromotionsItemsTable', () => { sites: { cf: { fragments: { - getByPath: sandbox.stub().resolves({ id: 'existing-var-id', path: promoVariationPath }), + search: makeSearchStub({ + [promoFolder]: [{ id: 'existing-var-id', path: promoVariationPath }], + }), }, }, }, @@ -1512,21 +1512,22 @@ describe('MasPromotionsItemsTable', () => { setupPromotionInEdit(); const otherPath = '/content/dam/mas/sandbox/en_US/other-card'; const otherFragment = { ...cardFragment, path: otherPath, id: 'other-card-id' }; - const otherTargetPath = buildPromoVariationPathForTag(otherPath, promoTag); Store.promotions.selectedCards.set([defaultPath]); const el = new MasPromotionsItemsTable(); el.type = TABLE_TYPE.CARDS; - let promoVariationLookupCount = 0; - const getByPathStub = sandbox.stub().callsFake((path) => { - if (path === promoVariationPath) { - promoVariationLookupCount += 1; - if (promoVariationLookupCount === 1) { - return Promise.resolve({ id: 'existing-var-id', path: promoVariationPath }); - } - return Promise.reject(new Error('network blip')); + let searchCallCount = 0; + const search = sandbox.stub().callsFake(async function* (query) { + if (query?.path !== promoFolder) { + yield []; + return; + } + searchCallCount += 1; + if (searchCallCount === 1) { + yield [{ id: 'existing-var-id', path: promoVariationPath }]; + return; } - return Promise.resolve(null); + throw new Error('network blip'); }); sandbox.stub(el, 'repository').get(() => ({ aem: { @@ -1535,7 +1536,7 @@ describe('MasPromotionsItemsTable', () => { .callsFake((path) => Promise.resolve(path === defaultPath ? { ...cardFragment } : otherFragment)), sites: { cf: { - fragments: { getByPath: getByPathStub }, + fragments: { search }, }, }, }, @@ -1565,6 +1566,7 @@ describe('MasPromotionsItemsTable', () => { const el = await fixture(html``); sandbox.stub(el, 'repository').get(() => ({ refreshFragment: sandbox.stub().resolves(), + loadPromotions: sandbox.stub().resolves(), aem, })); el.viewOnlyFragments = [cardFragment]; diff --git a/studio/test/promotions/promotion-publish-utils.test.js b/studio/test/promotions/promotion-publish-utils.test.js index 55f0d48f3..d612786b0 100644 --- a/studio/test/promotions/promotion-publish-utils.test.js +++ b/studio/test/promotions/promotion-publish-utils.test.js @@ -23,8 +23,11 @@ import { publishedPromoVariationsUnpublishMessage, promotionDeleteConfirmMessage, } from '../../src/promotions/promotion-publish-utils.js'; +import { makeSearchStub as makeSharedSearchStub } from '../helpers/aem-tag-fetch.js'; describe('promotion-publish-utils', () => { + const makeSearchStub = (itemsByFolder = {}) => makeSharedSearchStub(sinon, itemsByFolder); + it('isPromotionExpiredForPublish returns true only when promotionStatus is expired', () => { expect(isPromotionExpiredForPublish({ promotionStatus: 'expired' })).to.be.true; expect(isPromotionExpiredForPublish({ promotionStatus: 'active' })).to.be.false; @@ -98,13 +101,15 @@ describe('promotion-publish-utils', () => { it('shows dialog and returns not confirmed when user cancels', async () => { const parentPath = '/content/dam/mas/sandbox/en_US/my-card'; - const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sinon.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'promo-var-id', path: promoPath, status: 'DRAFT', title: 'V1' }); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const promoPath = `${promoFolder}/my-card`; + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'DRAFT', title: 'V1' }], + }); const aem = { sites: { cf: { - fragments: { getByPath }, + fragments: { search }, }, }, }; @@ -129,16 +134,18 @@ describe('promotion-publish-utils', () => { it('returns variation paths when user confirms publish together', async () => { const parentPaths = ['/content/dam/mas/sandbox/en_US/card-a', '/content/dam/mas/sandbox/en_US/card-b']; - const path1 = '/content/dam/mas/sandbox/en_US/promotions/black-friday/card-a'; - const path2 = '/content/dam/mas/sandbox/en_US/promotions/black-friday/card-b'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const path1 = `${promoFolder}/card-a`; + const path2 = `${promoFolder}/card-b`; const aem = { sites: { cf: { fragments: { - getByPath: sinon.stub().callsFake(async (path) => { - if (path === path1) return { id: 'variation-id-1', path: path1, status: 'DRAFT', title: 'V1' }; - if (path === path2) return { id: 'variation-id-2', path: path2, status: 'DRAFT', title: 'V2' }; - return null; + search: makeSearchStub({ + [promoFolder]: [ + { id: 'variation-id-1', path: path1, status: 'DRAFT', title: 'V1' }, + { id: 'variation-id-2', path: path2, status: 'DRAFT', title: 'V2' }, + ], }), }, }, @@ -189,13 +196,15 @@ describe('promotion-publish-utils', () => { it('shows unpublish dialog and returns not confirmed when user cancels', async () => { const parentPath = '/content/dam/mas/sandbox/en_US/my-card'; - const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sinon.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'promo-var-id', path: promoPath, status: 'PUBLISHED', title: 'V1' }); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const promoPath = `${promoFolder}/my-card`; + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'PUBLISHED', title: 'V1' }], + }); const aem = { sites: { cf: { - fragments: { getByPath }, + fragments: { search }, }, }, }; @@ -220,16 +229,18 @@ describe('promotion-publish-utils', () => { it('returns variation paths when user confirms unpublish together, including modified ones', async () => { const parentPaths = ['/content/dam/mas/sandbox/en_US/card-a', '/content/dam/mas/sandbox/en_US/card-b']; - const path1 = '/content/dam/mas/sandbox/en_US/promotions/black-friday/card-a'; - const path2 = '/content/dam/mas/sandbox/en_US/promotions/black-friday/card-b'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const path1 = `${promoFolder}/card-a`; + const path2 = `${promoFolder}/card-b`; const aem = { sites: { cf: { fragments: { - getByPath: sinon.stub().callsFake(async (path) => { - if (path === path1) return { id: 'variation-id-1', path: path1, status: 'PUBLISHED', title: 'V1' }; - if (path === path2) return { id: 'variation-id-2', path: path2, status: 'MODIFIED', title: 'V2' }; - return null; + search: makeSearchStub({ + [promoFolder]: [ + { id: 'variation-id-1', path: path1, status: 'PUBLISHED', title: 'V1' }, + { id: 'variation-id-2', path: path2, status: 'MODIFIED', title: 'V2' }, + ], }), }, }, diff --git a/studio/test/promotions/promotion-variations.test.js b/studio/test/promotions/promotion-variations.test.js index 78b5ca5e6..69c539f61 100644 --- a/studio/test/promotions/promotion-variations.test.js +++ b/studio/test/promotions/promotion-variations.test.js @@ -10,12 +10,14 @@ import { mergePromoReferencesForDefaultFragment, probePromoVariationReferences, probePromoVariationsForFragment, + probePromoVariationsForFragments, getUnpublishedAttachedPromoVariations, getAllAttachedPromoVariations, getPublishedAttachedPromoVariations, deleteAttachedPromoVariations, resolveDefaultFragmentForPromoVariation, } from '../../src/promotions/promotion-variations.js'; +import { makeSearchStub as makeSharedSearchStub } from '../helpers/aem-tag-fetch.js'; describe('promotion-variations', () => { let sandbox; @@ -28,12 +30,15 @@ describe('promotion-variations', () => { sandbox.restore(); }); + const makeSearchStub = (itemsByFolder = {}) => makeSharedSearchStub(sandbox, itemsByFolder); + const createAemMock = (overrides = {}) => ({ sites: { cf: { fragments: { getByPath: sandbox.stub(), getById: sandbox.stub(), + search: makeSearchStub(), ensureFolderExists: sandbox.stub().resolves(), pollCreatedFragment: sandbox.stub(), ...overrides.fragments, @@ -58,7 +63,8 @@ describe('promotion-variations', () => { tags: [{ id: 'mas:product_code/cc' }], }; const promoTag = 'mas:promotion/black-friday'; - const targetPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const targetPath = `${promoFolder}/my-card`; it('creates the first (unsuffixed) promo variation and writes the given geo tags', async () => { const createdDraft = { id: 'new-promo-var-id' }; @@ -67,7 +73,6 @@ describe('promotion-variations', () => { const aem = createAemMock({ fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath: sandbox.stub().resolves(null), pollCreatedFragment: sandbox.stub().resolves(createdFragment), }, createFragmentCopy, @@ -88,19 +93,21 @@ describe('promotion-variations', () => { it('creates a second variation with a suffixed path when the first already exists', async () => { const variation1Path = targetPath; const variation2Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-2'; - const getByPath = sandbox.stub(); - getByPath.withArgs(variation1Path).resolves({ - id: 'var-1', - path: variation1Path, - fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + const search = makeSearchStub({ + [promoFolder]: [ + { + id: 'var-1', + path: variation1Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + }, + ], }); - getByPath.resolves(null); const createdDraft = { id: 'new-promo-var-2' }; const createdFragment = { id: 'new-promo-var-2', path: variation2Path }; const aem = createAemMock({ fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath, + search, pollCreatedFragment: sandbox.stub().resolves(createdFragment), }, createFragmentCopy: sandbox.stub().resolves(createdDraft), @@ -111,15 +118,17 @@ describe('promotion-variations', () => { }); it('throws when the requested geo tags overlap with a sibling variation', async () => { - const getByPath = sandbox.stub(); - getByPath.withArgs(targetPath).resolves({ - id: 'var-1', - path: targetPath, - fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + const search = makeSearchStub({ + [promoFolder]: [ + { + id: 'var-1', + path: targetPath, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + }, + ], }); - getByPath.resolves(null); const aem = createAemMock({ - fragments: { getById: sandbox.stub().resolves(parentFragment), getByPath }, + fragments: { getById: sandbox.stub().resolves(parentFragment), search }, }); try { @@ -131,15 +140,11 @@ describe('promotion-variations', () => { }); it('throws when requesting a geo-less variation and a geo-less sibling already exists', async () => { - const getByPath = sandbox.stub(); - getByPath.withArgs(targetPath).resolves({ - id: 'var-1', - path: targetPath, - fields: [], + const search = makeSearchStub({ + [promoFolder]: [{ id: 'var-1', path: targetPath, fields: [] }], }); - getByPath.resolves(null); const aem = createAemMock({ - fragments: { getById: sandbox.stub().resolves(parentFragment), getByPath }, + fragments: { getById: sandbox.stub().resolves(parentFragment), search }, }); try { @@ -155,17 +160,15 @@ describe('promotion-variations', () => { id: 'new-promo-var-2', path: '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-2', }; - const getByPath = sandbox.stub(); - getByPath.withArgs(targetPath).resolves({ - id: 'var-1', - path: targetPath, - fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + const search = makeSearchStub({ + [promoFolder]: [ + { id: 'var-1', path: targetPath, fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }] }, + ], }); - getByPath.resolves(null); const aem = createAemMock({ fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath, + search, pollCreatedFragment: sandbox.stub().resolves(createdFragment), }, createFragmentCopy: sandbox.stub().resolves({ id: 'new-promo-var-2' }), @@ -177,18 +180,14 @@ describe('promotion-variations', () => { it('creates a geo-specific variation alongside a sibling with no pznTags (legacy fallback variation)', async () => { const variation2Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-2'; - const getByPath = sandbox.stub(); - getByPath.withArgs(targetPath).resolves({ - id: 'var-1', - path: targetPath, - fields: [], + const search = makeSearchStub({ + [promoFolder]: [{ id: 'var-1', path: targetPath, fields: [] }], }); - getByPath.resolves(null); const createdFragment = { id: 'new-promo-var-2', path: variation2Path }; const aem = createAemMock({ fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath, + search, pollCreatedFragment: sandbox.stub().resolves(createdFragment), }, createFragmentCopy: sandbox.stub().resolves({ id: 'new-promo-var-2' }), @@ -202,17 +201,19 @@ describe('promotion-variations', () => { const variation1Path = targetPath; const collidingAttachedPath = '/content/dam/mas/sandbox/en_US/my-card-2'; const variation3Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-3'; - const getByPath = sandbox.stub(); - getByPath.withArgs(variation1Path).resolves({ - id: 'var-1', - path: variation1Path, - fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + const search = makeSearchStub({ + [promoFolder]: [ + { + id: 'var-1', + path: variation1Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + }, + ], }); - getByPath.resolves(null); const aem = createAemMock({ fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath, + search, pollCreatedFragment: sandbox.stub().resolves({ id: 'new-promo-var-3', path: variation3Path }), }, createFragmentCopy: sandbox.stub().resolves({ id: 'new-promo-var-3' }), @@ -292,7 +293,6 @@ describe('promotion-variations', () => { const aem = createAemMock({ fragments: { getById: sandbox.stub().resolves({ ...parentFragment, path: unparsablePath }), - getByPath: sandbox.stub().resolves(null), }, }); @@ -308,7 +308,6 @@ describe('promotion-variations', () => { const aem = createAemMock({ fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath: sandbox.stub().resolves(null), pollCreatedFragment: sandbox.stub().resolves(null), }, createFragmentCopy: sandbox.stub().resolves({ id: 'new-promo-var-id' }), @@ -327,7 +326,6 @@ describe('promotion-variations', () => { const aem = createAemMock({ fragments: { getById: sandbox.stub().resolves({ ...parentFragment, tags: undefined }), - getByPath: sandbox.stub().resolves(null), pollCreatedFragment: sandbox.stub().resolves(createdFragment), }, createFragmentCopy: sandbox.stub().resolves({ id: 'new-promo-var-id' }), @@ -344,7 +342,6 @@ describe('promotion-variations', () => { const aemForFallback = createAemMock({ fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath: sandbox.stub().resolves(null), pollCreatedFragment: sandbox.stub().resolves(createdFragment), }, createFragmentCopy: sandbox.stub().resolves(createdDraft), @@ -361,17 +358,13 @@ describe('promotion-variations', () => { expect(findOverlappingGeoTags(existingVariations, ['mas:pzn/country/ar'])).to.deep.equal([]); const secondVariationPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-2'; - const getByPathForSecond = sandbox.stub(); - getByPathForSecond.withArgs(targetPath).resolves({ - id: 'fallback-var', - path: targetPath, - fields: [], + const searchForSecond = makeSearchStub({ + [promoFolder]: [{ id: 'fallback-var', path: targetPath, fields: [] }], }); - getByPathForSecond.resolves(null); const aemForSecond = createAemMock({ fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath: getByPathForSecond, + search: searchForSecond, pollCreatedFragment: sandbox.stub().resolves({ id: 'second-var', path: secondVariationPath }), }, }); @@ -384,6 +377,7 @@ describe('promotion-variations', () => { describe('probePromoVariationsForFragment', () => { const defaultPath = '/content/dam/mas/sandbox/en_US/my-card'; const promoTag = 'mas:promotion/black-friday'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; it('returns an empty array when aem, defaultPath or promoTagId is missing', async () => { expect(await probePromoVariationsForFragment(null, defaultPath, promoTag)).to.deep.equal([]); @@ -392,23 +386,23 @@ describe('promotion-variations', () => { }); it('returns an empty array when the unsuffixed variation does not exist', async () => { - const aem = createAemMock({ - fragments: { getByPath: sandbox.stub().resolves(null) }, - }); + const aem = createAemMock({ fragments: { search: makeSearchStub() } }); const result = await probePromoVariationsForFragment(aem, defaultPath, promoTag); expect(result).to.deep.equal([]); }); - it('returns one entry for the unsuffixed variation and stops at the first missing suffix', async () => { + it('returns one entry for the unsuffixed variation when no suffixed siblings exist', async () => { const variation1Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub(); - getByPath.withArgs(variation1Path).resolves({ - id: 'var-1', - path: variation1Path, - fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + const search = makeSearchStub({ + [promoFolder]: [ + { + id: 'var-1', + path: variation1Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + }, + ], }); - getByPath.resolves(null); - const aem = createAemMock({ fragments: { getByPath } }); + const aem = createAemMock({ fragments: { search } }); const result = await probePromoVariationsForFragment(aem, defaultPath, promoTag); expect(result).to.have.lengthOf(1); @@ -425,22 +419,24 @@ describe('promotion-variations', () => { }); }); - it('finds multiple suffixed variations in order and stops at the first missing one', async () => { + it('finds multiple suffixed variations in order when they are all contiguous', async () => { const variation1Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; const variation2Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-2'; - const getByPath = sandbox.stub(); - getByPath.withArgs(variation1Path).resolves({ - id: 'var-1', - path: variation1Path, - fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + const search = makeSearchStub({ + [promoFolder]: [ + { + id: 'var-1', + path: variation1Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + }, + { + id: 'var-2', + path: variation2Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/fr'] }], + }, + ], }); - getByPath.withArgs(variation2Path).resolves({ - id: 'var-2', - path: variation2Path, - fields: [{ name: 'pznTags', values: ['mas:pzn/country/fr'] }], - }); - getByPath.resolves(null); - const aem = createAemMock({ fragments: { getByPath } }); + const aem = createAemMock({ fragments: { search } }); const result = await probePromoVariationsForFragment(aem, defaultPath, promoTag); expect(result).to.have.lengthOf(2); @@ -457,6 +453,84 @@ describe('promotion-variations', () => { tags: undefined, }); }); + + it('finds a variation past a gap left by deleting a lower-indexed sibling', async () => { + const variation1Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; + const variation3Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-3'; + const search = makeSearchStub({ + [promoFolder]: [ + { + id: 'var-1', + path: variation1Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + }, + { + id: 'var-3', + path: variation3Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/eg'] }], + }, + ], + }); + const aem = createAemMock({ fragments: { search } }); + + const result = await probePromoVariationsForFragment(aem, defaultPath, promoTag); + expect(result.map((variation) => variation.index)).to.deep.equal([1, 3]); + expect(result[1].id).to.equal('var-3'); + }); + }); + + describe('probePromoVariationsForFragments', () => { + const promoTag = 'mas:promotion/black-friday'; + + it('returns an empty array per default path when aem or promoTagId is missing', async () => { + const paths = ['/content/dam/mas/sandbox/en_US/my-card']; + const resultWithNoAem = await probePromoVariationsForFragments(null, paths, promoTag); + expect(resultWithNoAem.get(paths[0])).to.deep.equal([]); + const resultWithNoTag = await probePromoVariationsForFragments(createAemMock(), paths, ''); + expect(resultWithNoTag.get(paths[0])).to.deep.equal([]); + }); + + it('groups fragments that resolve to the same parent folder into a single search call', async () => { + const cardAPath = '/content/dam/mas/sandbox/en_US/card-a'; + const cardBPath = '/content/dam/mas/sandbox/en_US/card-b'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const search = makeSearchStub({ + [promoFolder]: [ + { id: 'var-a', path: `${promoFolder}/card-a`, fields: [] }, + { id: 'var-b', path: `${promoFolder}/card-b`, fields: [] }, + ], + }); + const aem = createAemMock({ fragments: { search } }); + + const result = await probePromoVariationsForFragments(aem, [cardAPath, cardBPath], promoTag); + + expect(search.calledOnce, 'should search the shared folder once for both fragments').to.be.true; + expect(result.get(cardAPath).map((variation) => variation.path)).to.deep.equal([`${promoFolder}/card-a`]); + expect(result.get(cardBPath).map((variation) => variation.path)).to.deep.equal([`${promoFolder}/card-b`]); + }); + + it("does not attribute a sibling fragment's own leaf name as this fragment's suffixed variation", async () => { + const cardPath = '/content/dam/mas/sandbox/en_US/dir/card'; + const card2Path = '/content/dam/mas/sandbox/en_US/dir/card-2'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday/dir'; + const search = makeSearchStub({ + [promoFolder]: [ + { id: 'var-card', path: `${promoFolder}/card`, fields: [] }, + { id: 'var-card-2', path: `${promoFolder}/card-2`, fields: [] }, + ], + }); + const aem = createAemMock({ fragments: { search } }); + + const result = await probePromoVariationsForFragments(aem, [cardPath, card2Path], promoTag); + + const cardVariations = result.get(cardPath); + expect(cardVariations).to.have.lengthOf(1); + expect(cardVariations[0].path).to.equal(`${promoFolder}/card`); + const card2Variations = result.get(card2Path); + expect(card2Variations).to.have.lengthOf(1); + expect(card2Variations[0].path).to.equal(`${promoFolder}/card-2`); + expect(card2Variations[0].index).to.equal(1); + }); }); describe('findOverlappingGeoTags', () => { @@ -506,18 +580,22 @@ describe('promotion-variations', () => { const defaultPath = '/content/dam/mas/sandbox/en_US/my-card'; it('returns 1 when there are no existing variations, regardless of attached fragments', () => { - expect(getNextAvailablePromoVariationIndex(0, defaultPath, ['/content/dam/mas/sandbox/en_US/my-card-2'])).to.equal( + expect(getNextAvailablePromoVariationIndex([], defaultPath, ['/content/dam/mas/sandbox/en_US/my-card-2'])).to.equal( 1, ); }); - it('returns existingCount + 1 when that index does not collide with an attached fragment', () => { - expect(getNextAvailablePromoVariationIndex(1, defaultPath, [])).to.equal(2); + it('returns the next index after the highest used one when it does not collide with an attached fragment', () => { + expect(getNextAvailablePromoVariationIndex([1], defaultPath, [])).to.equal(2); }); it('skips an index that would collide with another attached fragment in the same project', () => { const attached = ['/content/dam/mas/sandbox/en_US/my-card-2']; - expect(getNextAvailablePromoVariationIndex(1, defaultPath, attached)).to.equal(3); + expect(getNextAvailablePromoVariationIndex([1], defaultPath, attached)).to.equal(3); + }); + + it('fills a gap left by a deleted sibling instead of colliding with a surviving higher index', () => { + expect(getNextAvailablePromoVariationIndex([1, 3], defaultPath, [])).to.equal(2); }); it('throws when every index up to the safety cap collides with an attached fragment', () => { @@ -526,7 +604,7 @@ describe('promotion-variations', () => { attached.push(`/content/dam/mas/sandbox/en_US/my-card-${index}`); } try { - getNextAvailablePromoVariationIndex(1, defaultPath, attached); + getNextAvailablePromoVariationIndex([1], defaultPath, attached); expect.fail('Should have thrown'); } catch (err) { expect(err.message).to.include('Too many promo variations for this fragment'); @@ -535,6 +613,8 @@ describe('promotion-variations', () => { }); describe('getUnpublishedAttachedPromoVariations', () => { + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + it('returns unpublished promo variations resolved by tag and path', async () => { const promotionFragment = { getFieldValues: sandbox.stub().callsFake((name) => { @@ -544,14 +624,10 @@ describe('promotion-variations', () => { tags: [{ id: 'mas:promotion/black-friday' }], }; const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ - id: 'promo-var-id', - path: promoPath, - status: 'DRAFT', - title: 'Promo Card', + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'DRAFT', title: 'Promo Card' }], }); - const aem = createAemMock({ fragments: { getByPath } }); + const aem = createAemMock({ fragments: { search } }); const result = await getUnpublishedAttachedPromoVariations(aem, promotionFragment); expect(result).to.have.lengthOf(1); @@ -567,14 +643,10 @@ describe('promotion-variations', () => { tags: [{ id: 'mas:promotion/black-friday' }], }; const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ - id: 'promo-var-id', - path: promoPath, - status: 'MODIFIED', - title: 'Promo Card', + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'MODIFIED', title: 'Promo Card' }], }); - const aem = createAemMock({ fragments: { getByPath } }); + const aem = createAemMock({ fragments: { search } }); const result = await getUnpublishedAttachedPromoVariations(aem, promotionFragment); expect(result).to.have.lengthOf(1); @@ -590,14 +662,10 @@ describe('promotion-variations', () => { tags: [{ id: 'mas:promotion/black-friday' }], }; const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ - id: 'promo-var-id', - path: promoPath, - status: 'PUBLISHED', - title: 'Promo Card', + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'PUBLISHED', title: 'Promo Card' }], }); - const aem = createAemMock({ fragments: { getByPath } }); + const aem = createAemMock({ fragments: { search } }); const result = await getUnpublishedAttachedPromoVariations(aem, promotionFragment); expect(result).to.deep.equal([]); @@ -650,7 +718,7 @@ describe('promotion-variations', () => { }), tags: [{ id: 'mas:promotion/black-friday' }], }; - const aem = createAemMock({ fragments: { getByPath: sandbox.stub().resolves(null) } }); + const aem = createAemMock({ fragments: { search: makeSearchStub() } }); const result = await getUnpublishedAttachedPromoVariations(aem, promotionFragment); expect(result).to.deep.equal([]); }); @@ -665,10 +733,13 @@ describe('promotion-variations', () => { }; const variation1Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; const variation2Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-2'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(variation1Path).resolves({ id: 'var-1', path: variation1Path, status: 'PUBLISHED' }); - getByPath.withArgs(variation2Path).resolves({ id: 'var-2', path: variation2Path, status: 'DRAFT' }); - const aem = createAemMock({ fragments: { getByPath } }); + const search = makeSearchStub({ + [promoFolder]: [ + { id: 'var-1', path: variation1Path, status: 'PUBLISHED' }, + { id: 'var-2', path: variation2Path, status: 'DRAFT' }, + ], + }); + const aem = createAemMock({ fragments: { search } }); const result = await getUnpublishedAttachedPromoVariations(aem, promotionFragment); expect(result).to.have.lengthOf(1); @@ -677,6 +748,8 @@ describe('promotion-variations', () => { }); describe('getAllAttachedPromoVariations', () => { + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + it('includes published promo variations', async () => { const promotionFragment = { getFieldValues: sandbox.stub().callsFake((name) => { @@ -686,17 +759,20 @@ describe('promotion-variations', () => { tags: [{ id: 'mas:promotion/black-friday' }], }; const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ - id: 'promo-var-id', - path: promoPath, - status: 'PUBLISHED', - title: 'Promo Card', - model: { path: '/conf/mas/settings/dam/cfm/models/card' }, - fields: [{ name: 'cardTitle', values: ['Promo Card'] }], - tags: [], + const search = makeSearchStub({ + [promoFolder]: [ + { + id: 'promo-var-id', + path: promoPath, + status: 'PUBLISHED', + title: 'Promo Card', + model: { path: '/conf/mas/settings/dam/cfm/models/card' }, + fields: [{ name: 'cardTitle', values: ['Promo Card'] }], + tags: [], + }, + ], }); - const aem = createAemMock({ fragments: { getByPath } }); + const aem = createAemMock({ fragments: { search } }); const result = await getAllAttachedPromoVariations(aem, promotionFragment); expect(result).to.have.lengthOf(1); @@ -724,10 +800,13 @@ describe('promotion-variations', () => { }; const variation1Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; const variation2Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-2'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(variation1Path).resolves({ id: 'var-1', path: variation1Path, status: 'PUBLISHED' }); - getByPath.withArgs(variation2Path).resolves({ id: 'var-2', path: variation2Path, status: 'DRAFT' }); - const aem = createAemMock({ fragments: { getByPath } }); + const search = makeSearchStub({ + [promoFolder]: [ + { id: 'var-1', path: variation1Path, status: 'PUBLISHED' }, + { id: 'var-2', path: variation2Path, status: 'DRAFT' }, + ], + }); + const aem = createAemMock({ fragments: { search } }); const result = await getAllAttachedPromoVariations(aem, promotionFragment); expect(result).to.have.lengthOf(2); @@ -746,9 +825,42 @@ describe('promotion-variations', () => { const result = await getAllAttachedPromoVariations(aem, promotionFragment); expect(result).to.deep.equal([]); }); + + it('does not attribute a sibling fragment named "card-2" as "card"\'s own suffixed variation', async () => { + const cardPath = '/content/dam/mas/sandbox/en_US/dir/card'; + const card2Path = '/content/dam/mas/sandbox/en_US/dir/card-2'; + const dirPromoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday/dir'; + const promotionFragment = { + getFieldValues: sandbox.stub().callsFake((name) => { + if (name === 'fragments') return [cardPath, card2Path]; + return undefined; + }), + tags: [{ id: 'mas:promotion/black-friday' }], + }; + const search = makeSearchStub({ + [dirPromoFolder]: [ + { id: 'var-card', path: `${dirPromoFolder}/card`, fields: [] }, + { id: 'var-card-2', path: `${dirPromoFolder}/card-2`, fields: [] }, + ], + }); + const aem = createAemMock({ fragments: { search } }); + + const result = await getAllAttachedPromoVariations(aem, promotionFragment); + + expect(result).to.have.lengthOf(2); + const cardVariations = result.filter((variation) => variation.parentPath === cardPath); + expect(cardVariations).to.have.lengthOf(1); + expect(cardVariations[0].path).to.equal(`${dirPromoFolder}/card`); + const card2Variations = result.filter((variation) => variation.parentPath === card2Path); + expect(card2Variations).to.have.lengthOf(1); + expect(card2Variations[0].path).to.equal(`${dirPromoFolder}/card-2`); + expect(card2Variations[0].index).to.equal(1); + }); }); describe('getPublishedAttachedPromoVariations', () => { + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + it('returns only published promo variations, excluding drafts', async () => { const promotionFragment = { getFieldValues: sandbox.stub().callsFake((name) => { @@ -759,10 +871,13 @@ describe('promotion-variations', () => { }; const variation1Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; const variation2Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-2'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(variation1Path).resolves({ id: 'var-1', path: variation1Path, status: 'PUBLISHED' }); - getByPath.withArgs(variation2Path).resolves({ id: 'var-2', path: variation2Path, status: 'DRAFT' }); - const aem = createAemMock({ fragments: { getByPath } }); + const search = makeSearchStub({ + [promoFolder]: [ + { id: 'var-1', path: variation1Path, status: 'PUBLISHED' }, + { id: 'var-2', path: variation2Path, status: 'DRAFT' }, + ], + }); + const aem = createAemMock({ fragments: { search } }); const result = await getPublishedAttachedPromoVariations(aem, promotionFragment); expect(result).to.have.lengthOf(1); @@ -778,9 +893,10 @@ describe('promotion-variations', () => { tags: [{ id: 'mas:promotion/black-friday' }], }; const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'promo-var-id', path: promoPath, status: 'DRAFT' }); - const aem = createAemMock({ fragments: { getByPath } }); + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'DRAFT' }], + }); + const aem = createAemMock({ fragments: { search } }); const result = await getPublishedAttachedPromoVariations(aem, promotionFragment); expect(result).to.deep.equal([]); @@ -795,9 +911,10 @@ describe('promotion-variations', () => { tags: [{ id: 'mas:promotion/black-friday' }], }; const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'promo-var-id', path: promoPath, status: 'MODIFIED' }); - const aem = createAemMock({ fragments: { getByPath } }); + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'MODIFIED' }], + }); + const aem = createAemMock({ fragments: { search } }); const result = await getPublishedAttachedPromoVariations(aem, promotionFragment); expect(result).to.have.lengthOf(1); @@ -806,6 +923,8 @@ describe('promotion-variations', () => { }); describe('deleteAttachedPromoVariations', () => { + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + function makePromotionFragment(parentPaths) { return { getFieldValues: sandbox.stub().callsFake((name) => (name === 'fragments' ? parentPaths : undefined)), @@ -815,12 +934,13 @@ describe('promotion-variations', () => { it('unpublishes a published variation before deleting it', async () => { const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'var-1', path: promoPath, status: 'PUBLISHED' }); + const search = makeSearchStub({ + [promoFolder]: [{ id: 'var-1', path: promoPath, status: 'PUBLISHED' }], + }); const getWithEtag = sandbox.stub().withArgs('var-1').resolves({ id: 'var-1', etag: 'etag-1' }); const unpublish = sandbox.stub().resolves(); const forceDelete = sandbox.stub().resolves(); - const aem = createAemMock({ fragments: { getByPath, getWithEtag, unpublish, forceDelete } }); + const aem = createAemMock({ fragments: { search, getWithEtag, unpublish, forceDelete } }); await deleteAttachedPromoVariations(aem, makePromotionFragment(['/content/dam/mas/sandbox/en_US/my-card'])); @@ -831,12 +951,13 @@ describe('promotion-variations', () => { it('unpublishes a modified variation before deleting it', async () => { const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'var-1', path: promoPath, status: 'MODIFIED' }); + const search = makeSearchStub({ + [promoFolder]: [{ id: 'var-1', path: promoPath, status: 'MODIFIED' }], + }); const getWithEtag = sandbox.stub().withArgs('var-1').resolves({ id: 'var-1', etag: 'etag-1' }); const unpublish = sandbox.stub().resolves(); const forceDelete = sandbox.stub().resolves(); - const aem = createAemMock({ fragments: { getByPath, getWithEtag, unpublish, forceDelete } }); + const aem = createAemMock({ fragments: { search, getWithEtag, unpublish, forceDelete } }); await deleteAttachedPromoVariations(aem, makePromotionFragment(['/content/dam/mas/sandbox/en_US/my-card'])); @@ -846,12 +967,13 @@ describe('promotion-variations', () => { it('does not unpublish a draft variation, only deletes it', async () => { const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'var-1', path: promoPath, status: 'DRAFT' }); + const search = makeSearchStub({ + [promoFolder]: [{ id: 'var-1', path: promoPath, status: 'DRAFT' }], + }); const getWithEtag = sandbox.stub().withArgs('var-1').resolves({ id: 'var-1', etag: 'etag-1' }); const unpublish = sandbox.stub().resolves(); const forceDelete = sandbox.stub().resolves(); - const aem = createAemMock({ fragments: { getByPath, getWithEtag, unpublish, forceDelete } }); + const aem = createAemMock({ fragments: { search, getWithEtag, unpublish, forceDelete } }); await deleteAttachedPromoVariations(aem, makePromotionFragment(['/content/dam/mas/sandbox/en_US/my-card'])); @@ -862,14 +984,17 @@ describe('promotion-variations', () => { it('deletes every attached promo variation across multiple parent fragments', async () => { const path1 = '/content/dam/mas/sandbox/en_US/promotions/black-friday/card-a'; const path2 = '/content/dam/mas/sandbox/en_US/promotions/black-friday/card-b'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(path1).resolves({ id: 'var-a', path: path1, status: 'DRAFT' }); - getByPath.withArgs(path2).resolves({ id: 'var-b', path: path2, status: 'DRAFT' }); + const search = makeSearchStub({ + [promoFolder]: [ + { id: 'var-a', path: path1, status: 'DRAFT' }, + { id: 'var-b', path: path2, status: 'DRAFT' }, + ], + }); const getWithEtag = sandbox.stub(); getWithEtag.withArgs('var-a').resolves({ id: 'var-a', etag: 'etag-a' }); getWithEtag.withArgs('var-b').resolves({ id: 'var-b', etag: 'etag-b' }); const forceDelete = sandbox.stub().resolves(); - const aem = createAemMock({ fragments: { getByPath, getWithEtag, forceDelete } }); + const aem = createAemMock({ fragments: { search, getWithEtag, forceDelete } }); await deleteAttachedPromoVariations( aem, @@ -881,24 +1006,32 @@ describe('promotion-variations', () => { expect(forceDelete.calledWith({ path: path2 })).to.be.true; }); - it('continues deleting remaining variations when one fails', async () => { + it('attempts every variation even when one fails, then throws so the caller knows', async () => { const path1 = '/content/dam/mas/sandbox/en_US/promotions/black-friday/card-a'; const path2 = '/content/dam/mas/sandbox/en_US/promotions/black-friday/card-b'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(path1).resolves({ id: 'var-a', path: path1, status: 'DRAFT' }); - getByPath.withArgs(path2).resolves({ id: 'var-b', path: path2, status: 'DRAFT' }); + const search = makeSearchStub({ + [promoFolder]: [ + { id: 'var-a', path: path1, status: 'DRAFT' }, + { id: 'var-b', path: path2, status: 'DRAFT' }, + ], + }); const getWithEtag = sandbox.stub(); getWithEtag.withArgs('var-a').resolves({ id: 'var-a', etag: 'etag-a' }); getWithEtag.withArgs('var-b').resolves({ id: 'var-b', etag: 'etag-b' }); const forceDelete = sandbox.stub(); forceDelete.withArgs({ path: path1 }).rejects(new Error('delete failed')); forceDelete.withArgs({ path: path2 }).resolves(); - const aem = createAemMock({ fragments: { getByPath, getWithEtag, forceDelete } }); + const aem = createAemMock({ fragments: { search, getWithEtag, forceDelete } }); - await deleteAttachedPromoVariations( - aem, - makePromotionFragment(['/content/dam/mas/sandbox/en_US/card-a', '/content/dam/mas/sandbox/en_US/card-b']), - ); + try { + await deleteAttachedPromoVariations( + aem, + makePromotionFragment(['/content/dam/mas/sandbox/en_US/card-a', '/content/dam/mas/sandbox/en_US/card-b']), + ); + expect.fail('Should have thrown'); + } catch (err) { + expect(err.message).to.include(path1); + } expect(forceDelete.calledWith({ path: path2 })).to.be.true; }); @@ -906,16 +1039,14 @@ describe('promotion-variations', () => { describe('probePromoVariationReferences', () => { const defaultPath = '/content/dam/mas/sandbox/en_US/Plans/Individual/com/my-card'; - const promoPath = '/content/dam/mas/sandbox/en_US/promotions/back-to-school/Plans/Individual/com/my-card'; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/back-to-school/Plans/Individual/com'; + const promoPath = `${promoFolder}/my-card`; it('returns references for existing promo copies from promotion project tags', async () => { - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ - id: 'promo-var-id', - path: promoPath, - tags: [{ id: 'mas:promotion/back-to-school' }], + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, tags: [{ id: 'mas:promotion/back-to-school' }] }], }); - const aem = createAemMock({ fragments: { getByPath } }); + const aem = createAemMock({ fragments: { search } }); const refs = await probePromoVariationReferences(aem, defaultPath, [ { tags: [{ id: 'mas:promotion/back-to-school' }] }, @@ -925,11 +1056,14 @@ describe('promotion-variations', () => { }); it('returns every variation when the same project has more than one, geo-specific, promo variation', async () => { - const promoPath2 = '/content/dam/mas/sandbox/en_US/promotions/back-to-school/Plans/Individual/com/my-card-2'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'promo-var-1', path: promoPath, status: 'PUBLISHED' }); - getByPath.withArgs(promoPath2).resolves({ id: 'promo-var-2', path: promoPath2, status: 'DRAFT' }); - const aem = createAemMock({ fragments: { getByPath } }); + const promoPath2 = `${promoFolder}/my-card-2`; + const search = makeSearchStub({ + [promoFolder]: [ + { id: 'promo-var-1', path: promoPath, status: 'PUBLISHED' }, + { id: 'promo-var-2', path: promoPath2, status: 'DRAFT' }, + ], + }); + const aem = createAemMock({ fragments: { search } }); const refs = await probePromoVariationReferences(aem, defaultPath, [ { tags: [{ id: 'mas:promotion/back-to-school' }] }, @@ -951,7 +1085,7 @@ describe('promotion-variations', () => { it('excludes a project whose variation exists but is missing an id', async () => { const aem = createAemMock({ - fragments: { getByPath: sandbox.stub().withArgs(promoPath).resolves({ path: promoPath }) }, + fragments: { search: makeSearchStub({ [promoFolder]: [{ path: promoPath }] }) }, }); const refs = await probePromoVariationReferences(aem, defaultPath, [ @@ -999,10 +1133,12 @@ describe('promotion-variations', () => { it('merges probed promo references into fragment payload', async () => { const defaultPath = '/content/dam/mas/sandbox/en_US/my-card'; - const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'promo-1', path: promoPath, tags: [] }); - const aem = createAemMock({ fragments: { getByPath } }); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const promoPath = `${promoFolder}/my-card`; + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-1', path: promoPath, tags: [] }], + }); + const aem = createAemMock({ fragments: { search } }); const enriched = await mergePromoReferencesForDefaultFragment(aem, { path: defaultPath, references: [] }, [ { tags: [{ id: 'mas:promotion/black-friday' }] }, @@ -1104,6 +1240,29 @@ describe('promotion-variations', () => { expect(result).to.deep.equal(unstrippedData); }); + it('prefers the stripped candidate over a coincidentally-named suffixed fragment when neither is attached', async () => { + const suffixedPromoPath = '/content/dam/mas/sandbox/en_US/promotions/back-to-school/my-card-2'; + const unstrippedCandidate = '/content/dam/mas/sandbox/en_US/my-card-2'; + const strippedCandidate = '/content/dam/mas/sandbox/en_US/my-card'; + const strippedData = { id: 'default-id', path: strippedCandidate }; + const getByPath = sandbox.stub(); + getByPath.withArgs(unstrippedCandidate).resolves({ id: 'wrong-match', path: unstrippedCandidate }); + getByPath.withArgs(strippedCandidate).resolves(strippedData); + const aem = createAemMock({ + fragments: { + getById: sandbox.stub().resolves({ + id: 'promo-var-2', + path: suffixedPromoPath, + tags: [{ id: 'mas:promotion/back-to-school' }], + }), + getByPath, + }, + }); + + const result = await resolveDefaultFragmentForPromoVariation(aem, suffixedPromoPath, 'promo-var-2', []); + expect(result).to.deep.equal(strippedData); + }); + it('returns null when no candidate default path resolves to a real fragment', async () => { const aem = createAemMock({ fragments: { diff --git a/studio/test/promotions/promotions-repository.test.js b/studio/test/promotions/promotions-repository.test.js index 38e547fb4..8601f00a3 100644 --- a/studio/test/promotions/promotions-repository.test.js +++ b/studio/test/promotions/promotions-repository.test.js @@ -12,10 +12,13 @@ import { probePromoVariationsForFragment, resolveDefaultFragmentForPromoVariation, } from '../../src/promotions/promotions-repository.js'; +import { makeSearchStub as makeSharedSearchStub } from '../helpers/aem-tag-fetch.js'; describe('promotions-repository', () => { let sandbox; + const makeSearchStub = (itemsByFolder = {}) => makeSharedSearchStub(sandbox, itemsByFolder); + beforeEach(() => { sandbox = sinon.createSandbox(); }); @@ -73,12 +76,12 @@ describe('promotions-repository', () => { }, ]); Store.promotions.list.loading.set(false); - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'promo-var', path: promoPath }); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const search = makeSearchStub({ [promoFolder]: [{ id: 'promo-var', path: promoPath }] }); const aem = { sites: { cf: { - fragments: { getByPath }, + fragments: { search }, }, }, }; @@ -108,7 +111,7 @@ describe('promotions-repository', () => { cf: { fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath: sandbox.stub().resolves(null), + search: makeSearchStub(), ensureFolderExists: sandbox.stub().resolves(), pollCreatedFragment: sandbox.stub().resolves(createdFragment), }, @@ -126,7 +129,9 @@ describe('promotions-repository', () => { }; sandbox.stub(Store.fragments.list.data, 'get').returns([parentStore]); - const result = await createPromoVariation(aem, parentFragment.id, promoTag, [], refreshFragment); + const result = await createPromoVariation(aem, parentFragment.id, promoTag, [], refreshFragment, () => + Promise.resolve(), + ); expect(result).to.deep.equal(createdFragment); expect(refreshFragment.calledOnceWith(parentStore)).to.be.true; @@ -135,12 +140,14 @@ describe('promotions-repository', () => { it('passes the attached fragment paths of the matching promo project to the model layer', async () => { const createdFragment = { id: 'new-promo-var-id', path: targetPath }; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const search = makeSearchStub(); const aem = { sites: { cf: { fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath: sandbox.stub().resolves(null), + search, ensureFolderExists: sandbox.stub().resolves(), pollCreatedFragment: sandbox.stub().resolves(createdFragment), }, @@ -162,21 +169,20 @@ describe('promotions-repository', () => { await createPromoVariation(aem, parentFragment.id, promoTag, ['mas:pzn/country/ar']); - expect(aem.sites.cf.fragments.getByPath.calledWith(targetPath)).to.be.true; + expect(search.calledWith({ path: promoFolder }, 50)).to.be.true; }); it('creates a geo-specific variation even when a legacy sibling (no pznTags) already exists', async () => { const variation2Path = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card-2'; - const getByPath = sandbox.stub(); - getByPath.withArgs(targetPath).resolves({ id: 'existing-var', path: targetPath, fields: [] }); - getByPath.resolves(null); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const search = makeSearchStub({ [promoFolder]: [{ id: 'existing-var', path: targetPath, fields: [] }] }); const createdFragment = { id: 'new-promo-var-2', path: variation2Path }; const aem = { sites: { cf: { fragments: { getById: sandbox.stub().resolves(parentFragment), - getByPath, + search, ensureFolderExists: sandbox.stub().resolves(), pollCreatedFragment: sandbox.stub().resolves(createdFragment), }, @@ -246,15 +252,15 @@ describe('promotions-repository', () => { getFieldValues: (name) => (name === 'fragments' ? ['/content/dam/mas/sandbox/en_US/my-card'] : undefined), tags: [{ id: 'mas:promotion/black-friday' }], }; - const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath - .withArgs(promoPath) - .resolves({ id: 'promo-var-id', path: promoPath, status: 'DRAFT', title: 'Promo Card' }); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const promoPath = `${promoFolder}/my-card`; + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'DRAFT', title: 'Promo Card' }], + }); const aem = { sites: { cf: { - fragments: { getByPath }, + fragments: { search }, }, }, }; @@ -272,15 +278,15 @@ describe('promotions-repository', () => { getFieldValues: (name) => (name === 'fragments' ? ['/content/dam/mas/sandbox/en_US/my-card'] : undefined), tags: [{ id: 'mas:promotion/black-friday' }], }; - const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath - .withArgs(promoPath) - .resolves({ id: 'promo-var-id', path: promoPath, status: 'PUBLISHED', title: 'Promo Card' }); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const promoPath = `${promoFolder}/my-card`; + const search = makeSearchStub({ + [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'PUBLISHED', title: 'Promo Card' }], + }); const aem = { sites: { cf: { - fragments: { getByPath }, + fragments: { search }, }, }, }; @@ -298,14 +304,14 @@ describe('promotions-repository', () => { getFieldValues: (name) => (name === 'fragments' ? ['/content/dam/mas/sandbox/en_US/my-card'] : undefined), tags: [{ id: 'mas:promotion/black-friday' }], }; - const promoPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub().resolves(null); - getByPath.withArgs(promoPath).resolves({ id: 'promo-var-id', path: promoPath, status: 'DRAFT' }); + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const promoPath = `${promoFolder}/my-card`; + const search = makeSearchStub({ [promoFolder]: [{ id: 'promo-var-id', path: promoPath, status: 'DRAFT' }] }); const forceDelete = sandbox.stub().resolves(); const aem = { sites: { cf: { - fragments: { getByPath, forceDelete }, + fragments: { search, forceDelete }, }, }, }; @@ -320,11 +326,10 @@ describe('promotions-repository', () => { it('delegates to the promotion-variations model layer and returns its result', async () => { const defaultPath = '/content/dam/mas/sandbox/en_US/my-card'; const promoTag = 'mas:promotion/black-friday'; - const variationPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const getByPath = sandbox.stub(); - getByPath.withArgs(variationPath).resolves({ id: 'var-1', path: variationPath, fields: [] }); - getByPath.resolves(null); - const aem = { sites: { cf: { fragments: { getByPath } } } }; + const promoFolder = '/content/dam/mas/sandbox/en_US/promotions/black-friday'; + const variationPath = `${promoFolder}/my-card`; + const search = makeSearchStub({ [promoFolder]: [{ id: 'var-1', path: variationPath, fields: [] }] }); + const aem = { sites: { cf: { fragments: { search } } } }; const result = await probePromoVariationsForFragment(aem, defaultPath, promoTag);