From db0f9eec969b167f9e4e3c34d74f7e9daf2a7382 Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Wed, 22 Jul 2026 14:15:31 +0300 Subject: [PATCH 1/5] MWPW-201011: Geo Promo Variations - Follow up --- io/www/src/fragment/transformers/customize.js | 16 ++- studio/src/common/utils/selectable-list.js | 59 ++++++++++ studio/src/mas-fragment-editor.js | 6 +- .../promotions/mas-promo-variation-geos.js | 26 ++--- .../promotions/mas-promotions-items-table.js | 9 +- studio/src/promotions/promotion-variations.js | 91 ++++++++++----- .../src/promotions/promotions-repository.js | 5 +- .../translation/mas-translation-languages.js | 26 ++--- .../test/common/utils/selectable-list.test.js | 104 ++++++++++++++++++ .../mas-promotions-items-table.test.js | 52 +-------- .../promotions/promotion-variations.test.js | 78 +++++++++++-- .../promotions/promotions-repository.test.js | 4 +- 12 files changed, 348 insertions(+), 128 deletions(-) create mode 100644 studio/src/common/utils/selectable-list.js create mode 100644 studio/test/common/utils/selectable-list.test.js diff --git a/io/www/src/fragment/transformers/customize.js b/io/www/src/fragment/transformers/customize.js index 6fff72348..42ec7a9dc 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..a9c387e27 --- /dev/null +++ b/studio/src/common/utils/selectable-list.js @@ -0,0 +1,59 @@ +/** + * Shared building blocks for search + select-all + indeterminate-checkbox list UIs + * (e.g. mas-promo-variation-geos, mas-translation-languages). + */ + +/** + * Adds the search-box wiring shared by list-selector components (search query state, + * input handler, substring filter). Host component still owns its own `searchQuery` + * reactive property declaration and initial value. + * @param {typeof import('lit').LitElement} Base + */ +export const SearchableListMixin = (Base) => + class extends Base { + handleSearch(e) { + this.searchQuery = e.target.value; + } + + /** + * @param {Array} items + * @param {(item: any) => string} getSearchableText + * @returns {Array} + */ + filterBySearchQuery(items, getSearchableText) { + if (!this.searchQuery) return items; + const query = this.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 adf216f86..a07ba4776 100644 --- a/studio/src/mas-fragment-editor.js +++ b/studio/src/mas-fragment-editor.js @@ -1830,10 +1830,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(', ')}
`; } @@ -1867,7 +1867,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 8999cadbd..fe34b6dae 100644 --- a/studio/src/promotions/mas-promo-variation-geos.js +++ b/studio/src/promotions/mas-promo-variation-geos.js @@ -1,7 +1,13 @@ import { LitElement, html, nothing } from 'lit'; import { styles } from './mas-promo-variation-geos.css.js'; - -class MasPromoVariationGeos extends LitElement { +import { + SearchableListMixin, + computeSelectAllChecked, + computeSelectAllIndeterminate, + computeSelectionCountLabel, +} from '../common/utils/selectable-list.js'; + +class MasPromoVariationGeos extends SearchableListMixin(LitElement) { static styles = styles; static properties = { @@ -28,23 +34,19 @@ 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 this.filterBySearchQuery(this.geos, (geo) => geo); } 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 +62,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 747b688f3..6aa85785b 100644 --- a/studio/src/promotions/mas-promotions-items-table.js +++ b/studio/src/promotions/mas-promotions-items-table.js @@ -893,8 +893,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'); this.existingPromoVariationDefaultPaths = new Set([...this.existingPromoVariationDefaultPaths, item.path]); diff --git a/studio/src/promotions/promotion-variations.js b/studio/src/promotions/promotion-variations.js index 288403e9e..8f910cb09 100644 --- a/studio/src/promotions/promotion-variations.js +++ b/studio/src/promotions/promotion-variations.js @@ -15,6 +15,8 @@ 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; /** @@ -27,8 +29,8 @@ function readPznTags(fragment) { } /** - * Sequentially probes and returns all existing promo variations for a given fragment. - * Stops at the first missing index. + * Probes every index (1..MAX) rather than stopping at the first miss — variations can be + * deleted individually, leaving gaps. * @param {import('../aem/aem.js').AEM} aem * @param {string} defaultPath * @param {string} promoTagId @@ -36,15 +38,19 @@ function readPznTags(fragment) { */ export async function probePromoVariationsForFragment(aem, defaultPath, promoTagId) { if (!aem || !defaultPath || !promoTagId) return []; - const found = []; + const candidates = []; 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; + candidates.push({ path: targetPath, index }); + } + const variations = await Promise.all(candidates.map(({ path }) => getFragmentByPathOrNull(aem.sites.cf.fragments, path))); + return candidates.reduce((found, { path, index }, i) => { + const variation = variations[i]; + if (!variation?.id) return found; found.push({ - path: targetPath, + path, index, id: variation.id, pznTags: readPznTags(variation), @@ -54,8 +60,8 @@ export async function probePromoVariationsForFragment(aem, defaultPath, promoTag fields: variation.fields, tags: variation.tags, }); - } - return found; + return found; + }, []); } /** @@ -80,16 +86,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; @@ -135,7 +143,7 @@ export async function createPromoVariation(aem, sourceFragmentId, promoTagId, ge } const nextIndex = getNextAvailablePromoVariationIndex( - existingVariations.length, + existingVariations.map((variation) => variation.index), sourceFragment.path, attachedFragmentPaths, ); @@ -230,10 +238,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] @@ -258,7 +280,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); @@ -321,24 +345,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..533d2ba23 100644 --- a/studio/src/translation/mas-translation-languages.js +++ b/studio/src/translation/mas-translation-languages.js @@ -3,8 +3,14 @@ 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'; - -class MasTranslationLanguages extends LitElement { +import { + SearchableListMixin, + computeSelectAllChecked, + computeSelectAllIndeterminate, + computeSelectionCountLabel, +} from '../common/utils/selectable-list.js'; + +class MasTranslationLanguages extends SearchableListMixin(LitElement) { static styles = styles; static properties = { @@ -38,23 +44,19 @@ 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 this.filterBySearchQuery(this.localesArray, (item) => item.locale); } 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 +72,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..e749e5d3a --- /dev/null +++ b/studio/test/common/utils/selectable-list.test.js @@ -0,0 +1,104 @@ +import { expect } from '@esm-bundle/chai'; +import { LitElement } from 'lit'; +import { + SearchableListMixin, + computeSelectAllChecked, + computeSelectAllIndeterminate, + computeSelectionCountLabel, +} from '../../../src/common/utils/selectable-list.js'; + +class SearchableListTestHost extends SearchableListMixin(LitElement) { + static properties = { searchQuery: { type: String, state: true } }; + + constructor() { + super(); + this.searchQuery = ''; + } +} +customElements.define('searchable-list-test-host', SearchableListTestHost); + +describe('selectable-list', () => { + 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'); + }); + }); + + describe('SearchableListMixin', () => { + let host; + + beforeEach(() => { + host = new SearchableListTestHost(); + }); + + it('sets searchQuery from a search input event', () => { + host.handleSearch({ target: { value: 'fr' } }); + expect(host.searchQuery).to.equal('fr'); + }); + + it('returns all items unfiltered when searchQuery is empty', () => { + const items = ['a', 'b', 'c']; + expect(host.filterBySearchQuery(items, (item) => item)).to.deep.equal(items); + }); + + it('filters items case-insensitively by the extracted searchable text', () => { + host.searchQuery = 'FR'; + const items = ['mas:pzn/country/fr', 'mas:pzn/country/ae']; + expect(host.filterBySearchQuery(items, (item) => item)).to.deep.equal(['mas:pzn/country/fr']); + }); + + it('applies getSearchableText per item rather than filtering the raw item', () => { + host.searchQuery = 'en'; + const items = [{ locale: 'en_US' }, { locale: 'fr_FR' }]; + expect(host.filterBySearchQuery(items, (item) => item.locale)).to.deep.equal([{ locale: 'en_US' }]); + }); + }); +}); diff --git a/studio/test/promotions/mas-promotions-items-table.test.js b/studio/test/promotions/mas-promotions-items-table.test.js index 994b17803..1263bc6a3 100644 --- a/studio/test/promotions/mas-promotions-items-table.test.js +++ b/studio/test/promotions/mas-promotions-items-table.test.js @@ -677,55 +677,6 @@ describe('MasPromotionsItemsTable', () => { Store.promotions.inEdit.set(null); }); - it('shows View promo variation instead of Create when a variation already exists for the project', async () => { - const defaultPath = '/content/dam/mas/sandbox/en_US/my-card'; - const promoVariationPath = '/content/dam/mas/sandbox/en_US/promotions/black-friday/my-card'; - const promotion = new Fragment({ - path: '/content/dam/mas/promotions/black-friday', - fields: [{ name: 'tags', values: ['mas:promotion/black-friday'], multiple: true }], - }); - Store.promotions.inEdit.set(new FragmentStore(promotion)); - Store.promotions.selectedCards.set([defaultPath]); - - const el = new MasPromotionsItemsTable(); - el.type = TABLE_TYPE.CARDS; - const fragment = { - path: defaultPath, - id: 'card-promo-id', - title: 'Promo Card', - studioPath: defaultPath, - status: 'DRAFT', - model: { path: CARD_MODEL_PATH }, - fields: [], - tags: [], - }; - sandbox.stub(el, 'repository').get(() => ({ - aem: { - getFragmentByPath: sandbox.stub().resolves(fragment), - sites: { - cf: { - fragments: { - getByPath: sandbox.stub().withArgs(promoVariationPath).resolves({ - id: 'promo-var-id', - path: promoVariationPath, - }), - }, - }, - }, - }, - })); - document.body.appendChild(el); - await el.updateComplete; - await new Promise((r) => setTimeout(r, 80)); - await el.updateComplete; - - const menuItems = Array.from(el.shadowRoot.querySelectorAll('sp-menu-item')); - expect(menuItems.some((item) => item.textContent.trim().includes('Create promo variation'))).to.be.false; - el.remove(); - Store.promotions.inEdit.set(null); - Store.promotions.selectedCards.set([]); - }); - it('hides Create promo variation for paths that are already promo variations', async () => { const promotion = new Fragment({ path: '/content/dam/mas/promotions/black-friday', @@ -928,6 +879,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]; @@ -1225,6 +1177,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]; @@ -1359,6 +1312,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-variations.test.js b/studio/test/promotions/promotion-variations.test.js index 170feaccf..253b25fe8 100644 --- a/studio/test/promotions/promotion-variations.test.js +++ b/studio/test/promotions/promotion-variations.test.js @@ -354,7 +354,7 @@ describe('promotion-variations', () => { 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({ @@ -380,7 +380,7 @@ 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(); @@ -412,6 +412,28 @@ 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 getByPath = sandbox.stub(); + getByPath.withArgs(variation1Path).resolves({ + id: 'var-1', + path: variation1Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/ar'] }], + }); + getByPath.withArgs(variation3Path).resolves({ + id: 'var-3', + path: variation3Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/eg'] }], + }); + getByPath.resolves(null); + const aem = createAemMock({ fragments: { getByPath } }); + + 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('findOverlappingGeoTags', () => { @@ -461,18 +483,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', () => { @@ -481,7 +507,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'); @@ -836,7 +862,7 @@ 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); @@ -850,10 +876,15 @@ describe('promotion-variations', () => { forceDelete.withArgs({ path: path2 }).resolves(); const aem = createAemMock({ fragments: { getByPath, 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; }); @@ -1059,6 +1090,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..9ab5bf877 100644 --- a/studio/test/promotions/promotions-repository.test.js +++ b/studio/test/promotions/promotions-repository.test.js @@ -126,7 +126,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; From 3fa712554be678497e90891f09662b41de22bf5f Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Thu, 23 Jul 2026 20:47:43 +0300 Subject: [PATCH 2/5] MWPW-201011: Add Nala test --- nala/docs/acom/promotions/promotions.spec.js | 11 ++++++++++ nala/docs/acom/promotions/promotions.test.js | 23 ++++++++++++++++++++ nala/utils/commerce.js | 1 + 3 files changed, 35 insertions(+) diff --git a/nala/docs/acom/promotions/promotions.spec.js b/nala/docs/acom/promotions/promotions.spec.js index af66c4e1f..708c336f0 100644 --- a/nala/docs/acom/promotions/promotions.spec.js +++ b/nala/docs/acom/promotions/promotions.spec.js @@ -95,5 +95,16 @@ export const features = [ browserParams: '?mas.preview=on', tags: '@mas-docs @mas-acom @mas-promotions @commerce @smoke @regression @milo', }, + { + tcid: '6', + name: '@MAS-Promotions-Geo-Variation-Survives-Sibling-Deletion', + path: DOCS_GALLERY_PATH.PLANS_COLLECTION.DE_co, + data: { + id: '9c718c8b-8807-4f2b-b41a-ae7f87d69832', + variation_id: '941b6280-0835-4b62-a8dd-bea443574264', + }, + browserParams: '?mas.preview=on', + tags: '@mas-docs @mas-acom @mas-promotions @commerce @smoke @regression @milo', + }, // add grouped variation card in grouped variation collection when MWPW-197436 is fixed ]; diff --git a/nala/docs/acom/promotions/promotions.test.js b/nala/docs/acom/promotions/promotions.test.js index fd6a80bf7..5f74f676b 100644 --- a/nala/docs/acom/promotions/promotions.test.js +++ b/nala/docs/acom/promotions/promotions.test.js @@ -393,4 +393,27 @@ test.describe('ACOM MAS Promotions feature test suite', () => { await expect(acomPage.getCardStrikethroughPrice(data.id)).toHaveCSS('text-decoration-line', 'line-through'); }); }); + + // @MAS-Promotions-Geo-Variation-Survives-Sibling-Deletion + test(`${features[6].name},${features[6].tags}`, async () => { + const { data } = features[6]; + + await test.step('step-1: Verify surviving geo variation on DE with preview', async () => { + const page = workerSetup.getPage('DE_co'); + const acomPage = new MasPlans(page); + await workerSetup.verifyPageURL('DE_co', DOCS_GALLERY_PATH.PLANS_COLLECTION.DE_co, expect); + await expect(acomPage.getCard(data.id)).toBeVisible(); + await expect(acomPage.getCard(data.id)).toHaveAttribute('variation-id', data.variation_id); + await expect(acomPage.getCard(data.id)).toHaveAttribute('data-promotion-project', /.+/); + }); + + await test.step('step-2: Verify surviving geo variation on DE without preview', async () => { + const page = workerSetup.getPage('DE_co_base'); + const acomPage = new MasPlans(page); + await workerSetup.verifyPageURL('DE_co_base', DOCS_GALLERY_PATH.PLANS_COLLECTION.DE_co, expect); + await expect(acomPage.getCard(data.id)).toBeVisible(); + await expect(acomPage.getCard(data.id)).toHaveAttribute('variation-id', data.variation_id); + await expect(acomPage.getCard(data.id)).toHaveAttribute('data-promotion-project', /.+/); + }); + }); }); diff --git a/nala/utils/commerce.js b/nala/utils/commerce.js index 93c7c3003..109168101 100644 --- a/nala/utils/commerce.js +++ b/nala/utils/commerce.js @@ -51,6 +51,7 @@ const DOCS_GALLERY_PATH = { AR_co: '/web-components/docs/plans-collection.html?country=AR', AR_ES_co: '/web-components/docs/plans-collection.html?locale=es_ES&country=AR', AR_ES: '/web-components/docs/plans-collection.html?locale=es_AR', + DE_co: '/web-components/docs/plans-collection.html?country=DE', }, MINICOMPARE: '/web-components/docs/minicompare.html', MINICOMPARE_MWEB: '/web-components/docs/minicomparemweb.html', From d13b99076bea45c94adeabd8b5f12a82f7ed71fa Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Mon, 27 Jul 2026 21:08:54 +0300 Subject: [PATCH 3/5] MWPW-201011: Add refactor --- studio/src/common/utils/selectable-list.js | 38 +- .../promotions/mas-promo-variation-geos.js | 11 +- studio/src/promotions/promotion-variations.js | 156 +++++-- .../translation/mas-translation-languages.js | 11 +- .../test/common/utils/selectable-list.test.js | 65 +-- studio/test/helpers/aem-tag-fetch.js | 13 + studio/test/mas-fragment-editor.test.js | 13 +- studio/test/mas-repository.test.js | 13 +- .../promotions/mas-promotions-editor.test.js | 201 ++++---- .../mas-promotions-items-table.test.js | 99 ++-- .../promotion-publish-utils.test.js | 51 ++- .../promotions/promotion-variations.test.js | 429 +++++++++++------- .../promotions/promotions-repository.test.js | 65 +-- 13 files changed, 689 insertions(+), 476 deletions(-) diff --git a/studio/src/common/utils/selectable-list.js b/studio/src/common/utils/selectable-list.js index a9c387e27..ee8d7d005 100644 --- a/studio/src/common/utils/selectable-list.js +++ b/studio/src/common/utils/selectable-list.js @@ -4,28 +4,26 @@ */ /** - * Adds the search-box wiring shared by list-selector components (search query state, - * input handler, substring filter). Host component still owns its own `searchQuery` - * reactive property declaration and initial value. - * @param {typeof import('lit').LitElement} Base + * Extracts the new search query value from a search input/change event. + * @param {Event} e + * @returns {string} */ -export const SearchableListMixin = (Base) => - class extends Base { - handleSearch(e) { - this.searchQuery = e.target.value; - } +export function handleSearchInput(e) { + return e.target.value; +} - /** - * @param {Array} items - * @param {(item: any) => string} getSearchableText - * @returns {Array} - */ - filterBySearchQuery(items, getSearchableText) { - if (!this.searchQuery) return items; - const query = this.searchQuery.toLowerCase(); - return items.filter((item) => getSearchableText(item).toLowerCase().includes(query)); - } - }; +/** + * 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 diff --git a/studio/src/promotions/mas-promo-variation-geos.js b/studio/src/promotions/mas-promo-variation-geos.js index fe34b6dae..9b133ccf4 100644 --- a/studio/src/promotions/mas-promo-variation-geos.js +++ b/studio/src/promotions/mas-promo-variation-geos.js @@ -1,13 +1,14 @@ import { LitElement, html, nothing } from 'lit'; import { styles } from './mas-promo-variation-geos.css.js'; import { - SearchableListMixin, + handleSearchInput, + filterBySearchQuery, computeSelectAllChecked, computeSelectAllIndeterminate, computeSelectionCountLabel, } from '../common/utils/selectable-list.js'; -class MasPromoVariationGeos extends SearchableListMixin(LitElement) { +class MasPromoVariationGeos extends LitElement { static styles = styles; static properties = { @@ -34,7 +35,11 @@ class MasPromoVariationGeos extends SearchableListMixin(LitElement) { } get filteredGeos() { - return this.filterBySearchQuery(this.geos, (geo) => geo); + return filterBySearchQuery(this.geos, this.searchQuery, (geo) => geo); + } + + handleSearch(e) { + this.searchQuery = handleSearchInput(e); } get selectAllChecked() { diff --git a/studio/src/promotions/promotion-variations.js b/studio/src/promotions/promotion-variations.js index 8f910cb09..8e1ee0a10 100644 --- a/studio/src/promotions/promotion-variations.js +++ b/studio/src/promotions/promotion-variations.js @@ -19,6 +19,9 @@ import { // 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 @@ -29,8 +32,106 @@ function readPznTags(fragment) { } /** - * Probes every index (1..MAX) rather than stopping at the first miss — variations can be - * deleted individually, leaving gaps. + * 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 @@ -38,30 +139,8 @@ function readPznTags(fragment) { */ export async function probePromoVariationsForFragment(aem, defaultPath, promoTagId) { if (!aem || !defaultPath || !promoTagId) return []; - const candidates = []; - 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; - candidates.push({ path: targetPath, index }); - } - const variations = await Promise.all(candidates.map(({ path }) => getFragmentByPathOrNull(aem.sites.cf.fragments, path))); - return candidates.reduce((found, { path, index }, i) => { - const variation = variations[i]; - if (!variation?.id) return found; - found.push({ - path, - 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) || []; } /** @@ -306,22 +385,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 })), + ); } /** diff --git a/studio/src/translation/mas-translation-languages.js b/studio/src/translation/mas-translation-languages.js index 533d2ba23..c68bd8461 100644 --- a/studio/src/translation/mas-translation-languages.js +++ b/studio/src/translation/mas-translation-languages.js @@ -4,13 +4,14 @@ import Store from '../store.js'; import { getDefaultLocales, getSurfaceLocales, getLocaleCode, REGION_GROUPS } from '../locales.js'; import ReactiveController from '../reactivity/reactive-controller.js'; import { - SearchableListMixin, + handleSearchInput, + filterBySearchQuery, computeSelectAllChecked, computeSelectAllIndeterminate, computeSelectionCountLabel, } from '../common/utils/selectable-list.js'; -class MasTranslationLanguages extends SearchableListMixin(LitElement) { +class MasTranslationLanguages extends LitElement { static styles = styles; static properties = { @@ -44,7 +45,11 @@ class MasTranslationLanguages extends SearchableListMixin(LitElement) { } get filteredLocales() { - return this.filterBySearchQuery(this.localesArray, (item) => item.locale); + return filterBySearchQuery(this.localesArray, this.searchQuery, (item) => item.locale); + } + + handleSearch(e) { + this.searchQuery = handleSearchInput(e); } get selectAllChecked() { diff --git a/studio/test/common/utils/selectable-list.test.js b/studio/test/common/utils/selectable-list.test.js index e749e5d3a..ba4a557dd 100644 --- a/studio/test/common/utils/selectable-list.test.js +++ b/studio/test/common/utils/selectable-list.test.js @@ -1,23 +1,36 @@ import { expect } from '@esm-bundle/chai'; -import { LitElement } from 'lit'; import { - SearchableListMixin, + handleSearchInput, + filterBySearchQuery, computeSelectAllChecked, computeSelectAllIndeterminate, computeSelectionCountLabel, } from '../../../src/common/utils/selectable-list.js'; -class SearchableListTestHost extends SearchableListMixin(LitElement) { - static properties = { searchQuery: { type: String, state: true } }; +describe('selectable-list', () => { + describe('handleSearchInput', () => { + it('extracts the value from the event target', () => { + expect(handleSearchInput({ target: { value: 'fr' } })).to.equal('fr'); + }); + }); - constructor() { - super(); - this.searchQuery = ''; - } -} -customElements.define('searchable-list-test-host', SearchableListTestHost); + 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('selectable-list', () => { describe('computeSelectAllChecked', () => { it('returns false when there are no selectable items', () => { expect(computeSelectAllChecked(0, 0)).to.be.false; @@ -71,34 +84,4 @@ describe('selectable-list', () => { expect(computeSelectionCountLabel(2, 3, 'country', 'countries')).to.equal('2 countries selected'); }); }); - - describe('SearchableListMixin', () => { - let host; - - beforeEach(() => { - host = new SearchableListTestHost(); - }); - - it('sets searchQuery from a search input event', () => { - host.handleSearch({ target: { value: 'fr' } }); - expect(host.searchQuery).to.equal('fr'); - }); - - it('returns all items unfiltered when searchQuery is empty', () => { - const items = ['a', 'b', 'c']; - expect(host.filterBySearchQuery(items, (item) => item)).to.deep.equal(items); - }); - - it('filters items case-insensitively by the extracted searchable text', () => { - host.searchQuery = 'FR'; - const items = ['mas:pzn/country/fr', 'mas:pzn/country/ae']; - expect(host.filterBySearchQuery(items, (item) => item)).to.deep.equal(['mas:pzn/country/fr']); - }); - - it('applies getSearchableText per item rather than filtering the raw item', () => { - host.searchQuery = 'en'; - const items = [{ locale: 'en_US' }, { locale: 'fr_FR' }]; - expect(host.filterBySearchQuery(items, (item) => item.locale)).to.deep.equal([{ locale: 'en_US' }]); - }); - }); }); 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 842cdacd0..08f007a2e 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 1263bc6a3..a6b738512 100644 --- a/studio/test/promotions/mas-promotions-items-table.test.js +++ b/studio/test/promotions/mas-promotions-items-table.test.js @@ -11,6 +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 { makeSearchStub as makeSharedSearchStub } from '../helpers/aem-tag-fetch.js'; describe('MasPromotionsItemsTable', () => { let sandbox; @@ -825,6 +826,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', @@ -840,7 +845,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, @@ -971,15 +976,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'] }], + }, + ], + }), }, }, }, @@ -1011,13 +1016,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: [] }], + }), }, }, }, @@ -1050,13 +1051,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: [] }], + }), }, }, }, @@ -1163,15 +1160,15 @@ 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``); @@ -1209,15 +1206,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``); @@ -1245,15 +1242,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``); 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 253b25fe8..cff35cf96 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 { @@ -132,18 +141,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' }), @@ -157,17 +162,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' }), @@ -247,7 +254,6 @@ describe('promotion-variations', () => { const aem = createAemMock({ fragments: { getById: sandbox.stub().resolves({ ...parentFragment, path: unparsablePath }), - getByPath: sandbox.stub().resolves(null), }, }); @@ -263,7 +269,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' }), @@ -282,7 +287,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' }), @@ -299,7 +303,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), @@ -316,17 +319,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 }), }, }); @@ -339,6 +338,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([]); @@ -347,23 +347,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 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); @@ -383,19 +383,21 @@ describe('promotion-variations', () => { 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); @@ -416,19 +418,21 @@ describe('promotion-variations', () => { 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 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-3', + path: variation3Path, + fields: [{ name: 'pznTags', values: ['mas:pzn/country/eg'] }], + }, + ], }); - getByPath.withArgs(variation3Path).resolves({ - id: 'var-3', - path: variation3Path, - fields: [{ name: 'pznTags', values: ['mas:pzn/country/eg'] }], - }); - getByPath.resolves(null); - const aem = createAemMock({ fragments: { getByPath } }); + const aem = createAemMock({ fragments: { search } }); const result = await probePromoVariationsForFragment(aem, defaultPath, promoTag); expect(result.map((variation) => variation.index)).to.deep.equal([1, 3]); @@ -436,6 +440,60 @@ describe('promotion-variations', () => { }); }); + 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', () => { it('returns geo tags already used by a sibling variation', () => { const existing = [{ pznTags: ['mas:pzn/country/ar', 'mas:pzn/country/ae'] }]; @@ -516,6 +574,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) => { @@ -525,14 +585,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); @@ -548,14 +604,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); @@ -571,14 +623,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([]); @@ -631,7 +679,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([]); }); @@ -646,10 +694,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); @@ -658,6 +709,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) => { @@ -667,17 +720,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); @@ -705,10 +761,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); @@ -727,9 +786,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) => { @@ -740,10 +832,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); @@ -759,9 +854,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([]); @@ -776,9 +872,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); @@ -787,6 +884,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)), @@ -796,12 +895,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'])); @@ -812,12 +912,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'])); @@ -827,12 +928,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'])); @@ -843,14 +945,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, @@ -865,16 +970,19 @@ describe('promotion-variations', () => { 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 } }); try { await deleteAttachedPromoVariations( @@ -892,16 +1000,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' }] }, @@ -911,11 +1017,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' }] }, @@ -937,7 +1046,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, [ @@ -985,10 +1094,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' }] }, diff --git a/studio/test/promotions/promotions-repository.test.js b/studio/test/promotions/promotions-repository.test.js index 9ab5bf877..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), }, @@ -137,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), }, @@ -164,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), }, @@ -248,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 }, }, }, }; @@ -274,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 }, }, }, }; @@ -300,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 }, }, }, }; @@ -322,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); From ca53a441957a692025aa20935c2c4876d5ca8d3f Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Mon, 27 Jul 2026 21:18:12 +0300 Subject: [PATCH 4/5] Remove nala test --- nala/docs/acom/promotions/promotions.spec.js | 11 ---------- nala/docs/acom/promotions/promotions.test.js | 23 -------------------- nala/utils/commerce.js | 1 - 3 files changed, 35 deletions(-) diff --git a/nala/docs/acom/promotions/promotions.spec.js b/nala/docs/acom/promotions/promotions.spec.js index 708c336f0..af66c4e1f 100644 --- a/nala/docs/acom/promotions/promotions.spec.js +++ b/nala/docs/acom/promotions/promotions.spec.js @@ -95,16 +95,5 @@ export const features = [ browserParams: '?mas.preview=on', tags: '@mas-docs @mas-acom @mas-promotions @commerce @smoke @regression @milo', }, - { - tcid: '6', - name: '@MAS-Promotions-Geo-Variation-Survives-Sibling-Deletion', - path: DOCS_GALLERY_PATH.PLANS_COLLECTION.DE_co, - data: { - id: '9c718c8b-8807-4f2b-b41a-ae7f87d69832', - variation_id: '941b6280-0835-4b62-a8dd-bea443574264', - }, - browserParams: '?mas.preview=on', - tags: '@mas-docs @mas-acom @mas-promotions @commerce @smoke @regression @milo', - }, // add grouped variation card in grouped variation collection when MWPW-197436 is fixed ]; diff --git a/nala/docs/acom/promotions/promotions.test.js b/nala/docs/acom/promotions/promotions.test.js index 5f74f676b..fd6a80bf7 100644 --- a/nala/docs/acom/promotions/promotions.test.js +++ b/nala/docs/acom/promotions/promotions.test.js @@ -393,27 +393,4 @@ test.describe('ACOM MAS Promotions feature test suite', () => { await expect(acomPage.getCardStrikethroughPrice(data.id)).toHaveCSS('text-decoration-line', 'line-through'); }); }); - - // @MAS-Promotions-Geo-Variation-Survives-Sibling-Deletion - test(`${features[6].name},${features[6].tags}`, async () => { - const { data } = features[6]; - - await test.step('step-1: Verify surviving geo variation on DE with preview', async () => { - const page = workerSetup.getPage('DE_co'); - const acomPage = new MasPlans(page); - await workerSetup.verifyPageURL('DE_co', DOCS_GALLERY_PATH.PLANS_COLLECTION.DE_co, expect); - await expect(acomPage.getCard(data.id)).toBeVisible(); - await expect(acomPage.getCard(data.id)).toHaveAttribute('variation-id', data.variation_id); - await expect(acomPage.getCard(data.id)).toHaveAttribute('data-promotion-project', /.+/); - }); - - await test.step('step-2: Verify surviving geo variation on DE without preview', async () => { - const page = workerSetup.getPage('DE_co_base'); - const acomPage = new MasPlans(page); - await workerSetup.verifyPageURL('DE_co_base', DOCS_GALLERY_PATH.PLANS_COLLECTION.DE_co, expect); - await expect(acomPage.getCard(data.id)).toBeVisible(); - await expect(acomPage.getCard(data.id)).toHaveAttribute('variation-id', data.variation_id); - await expect(acomPage.getCard(data.id)).toHaveAttribute('data-promotion-project', /.+/); - }); - }); }); diff --git a/nala/utils/commerce.js b/nala/utils/commerce.js index 109168101..93c7c3003 100644 --- a/nala/utils/commerce.js +++ b/nala/utils/commerce.js @@ -51,7 +51,6 @@ const DOCS_GALLERY_PATH = { AR_co: '/web-components/docs/plans-collection.html?country=AR', AR_ES_co: '/web-components/docs/plans-collection.html?locale=es_ES&country=AR', AR_ES: '/web-components/docs/plans-collection.html?locale=es_AR', - DE_co: '/web-components/docs/plans-collection.html?country=DE', }, MINICOMPARE: '/web-components/docs/minicompare.html', MINICOMPARE_MWEB: '/web-components/docs/minicomparemweb.html', From 8669196879e1eb314c70ea16b31419e47a39b87a Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Mon, 27 Jul 2026 22:45:05 +0300 Subject: [PATCH 5/5] Fix unit test --- .../promotions/mas-promotions-items-table.js | 2 +- .../mas-promotions-items-table.test.js | 28 ++++++++++--------- .../promotions/promotion-variations.test.js | 22 ++++++--------- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/studio/src/promotions/mas-promotions-items-table.js b/studio/src/promotions/mas-promotions-items-table.js index 0873d46f7..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(); diff --git a/studio/test/promotions/mas-promotions-items-table.test.js b/studio/test/promotions/mas-promotions-items-table.test.js index 7fcd789f0..4cda074f0 100644 --- a/studio/test/promotions/mas-promotions-items-table.test.js +++ b/studio/test/promotions/mas-promotions-items-table.test.js @@ -12,7 +12,6 @@ import '../../src/swc.js'; import MasPromotionsItemsTable from '../../src/promotions/mas-promotions-items-table.js'; import { buildPromotionOfferRecord } from '../../src/promotions/promotion-editor-utils.js'; import { makeSearchStub as makeSharedSearchStub } from '../helpers/aem-tag-fetch.js'; -import { buildPromoVariationPathForTag } from '../../src/promotions/promotion-model.js'; describe('MasPromotionsItemsTable', () => { let sandbox; @@ -1491,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 }], + }), }, }, }, @@ -1511,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: { @@ -1534,7 +1536,7 @@ describe('MasPromotionsItemsTable', () => { .callsFake((path) => Promise.resolve(path === defaultPath ? { ...cardFragment } : otherFragment)), sites: { cf: { - fragments: { getByPath: getByPathStub }, + fragments: { search }, }, }, }, diff --git a/studio/test/promotions/promotion-variations.test.js b/studio/test/promotions/promotion-variations.test.js index 95f15130e..69c539f61 100644 --- a/studio/test/promotions/promotion-variations.test.js +++ b/studio/test/promotions/promotion-variations.test.js @@ -140,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 { @@ -164,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' }),