diff --git a/src/support/slack/actions/entitlement-modal-utils.js b/src/support/slack/actions/entitlement-modal-utils.js index 6fdeae8cb..4423f067a 100644 --- a/src/support/slack/actions/entitlement-modal-utils.js +++ b/src/support/slack/actions/entitlement-modal-utils.js @@ -168,7 +168,9 @@ export async function updateMessageToProcessing(client, channelId, messageTs, ba } /** - * Creates entitlements for selected products in parallel + * Creates entitlements for selected products in parallel. + * If the org already has an entitlement for a product, preserves the existing tier + * and only creates the site enrollment if missing. * @param {object} lambdaContext - Lambda context * @param {object} site - Site object * @param {string[]} selectedProducts - Array of product codes @@ -177,6 +179,36 @@ export async function updateMessageToProcessing(client, channelId, messageTs, ba export async function createEntitlementsForProducts(lambdaContext, site, selectedProducts) { const entitlementPromises = selectedProducts.map(async (product) => { const tierClient = await TierClient.createForSite(lambdaContext, site, product); + const existing = await tierClient.checkValidEntitlement(); + + if (existing.entitlement) { + const currentTier = existing.entitlement.getTier(); + + if (existing.siteEnrollment) { + return { + product, + entitlementId: existing.entitlement.getId(), + enrollmentId: existing.siteEnrollment.getId(), + existingTier: currentTier, + alreadyExisted: true, + }; + } + + const { SiteEnrollment } = lambdaContext.dataAccess; + const siteEnrollment = await SiteEnrollment.create({ + siteId: site.getId(), + entitlementId: existing.entitlement.getId(), + }); + + return { + product, + entitlementId: existing.entitlement.getId(), + enrollmentId: siteEnrollment.getId(), + existingTier: currentTier, + enrollmentCreated: true, + }; + } + const { entitlement, siteEnrollment } = await tierClient.createEntitlement( EntitlementModel.TIERS.FREE_TRIAL, ); @@ -200,8 +232,17 @@ export async function createEntitlementsForProducts(lambdaContext, site, selecte export async function postEntitlementMessages(say, entitlementResults, siteId) { /* eslint-disable no-await-in-loop */ for (const result of entitlementResults) { - const message = `:white_check_mark: Ensured ${result.product} entitlement ${result.entitlementId} ` - + `(${EntitlementModel.TIERS.FREE_TRIAL}) and enrollment ${result.enrollmentId} for site ${siteId}`; + let message; + if (result.alreadyExisted) { + message = `:information_source: ${result.product} entitlement ${result.entitlementId} ` + + `(${result.existingTier}) and enrollment ${result.enrollmentId} already exist for site ${siteId}`; + } else if (result.enrollmentCreated) { + message = `:white_check_mark: ${result.product} entitlement ${result.entitlementId} ` + + `(${result.existingTier}) already existed — created enrollment ${result.enrollmentId} for site ${siteId}`; + } else { + message = `:white_check_mark: Created ${result.product} entitlement ${result.entitlementId} ` + + `(${EntitlementModel.TIERS.FREE_TRIAL}) and enrollment ${result.enrollmentId} for site ${siteId}`; + } await say(message); } /* eslint-enable no-await-in-loop */ diff --git a/src/support/slack/actions/entitlement-modals.js b/src/support/slack/actions/entitlement-modals.js index aea911e5a..a73a6490a 100644 --- a/src/support/slack/actions/entitlement-modals.js +++ b/src/support/slack/actions/entitlement-modals.js @@ -294,18 +294,31 @@ export function ensureEntitlementImsOrgModal(lambdaContext) { for (const product of selectedProducts) { try { const tierClient = TierClient.createForOrg(lambdaContext, organization, product); - const { entitlement } = await tierClient.createEntitlement( - EntitlementModel.TIERS.FREE_TRIAL, - ); - entitlementResults.push({ - product, - entitlementId: entitlement.getId(), - }); - - await say( - `:white_check_mark: Ensured ${product} entitlement ${entitlement.getId()} ` - + `(${EntitlementModel.TIERS.FREE_TRIAL}) for organization ${organizationId}`, - ); + const existing = await tierClient.checkValidEntitlement(); + + if (existing.entitlement) { + const currentTier = existing.entitlement.getTier(); + entitlementResults.push({ + product, + entitlementId: existing.entitlement.getId(), + }); + await say( + `:information_source: ${product} entitlement ${existing.entitlement.getId()} ` + + `(${currentTier}) already exists for organization ${organizationId}`, + ); + } else { + const { entitlement } = await tierClient.createEntitlement( + EntitlementModel.TIERS.FREE_TRIAL, + ); + entitlementResults.push({ + product, + entitlementId: entitlement.getId(), + }); + await say( + `:white_check_mark: Created ${product} entitlement ${entitlement.getId()} ` + + `(${EntitlementModel.TIERS.FREE_TRIAL}) for organization ${organizationId}`, + ); + } } catch (error) { log.error(`Error creating ${product} entitlement for org ${organizationId}:`, error); await say(`:x: Failed to ensure ${product} entitlement: ${error.message}`); diff --git a/test/support/slack/actions/entitlement-modal-utils.test.js b/test/support/slack/actions/entitlement-modal-utils.test.js index 3a803e278..423963f81 100644 --- a/test/support/slack/actions/entitlement-modal-utils.test.js +++ b/test/support/slack/actions/entitlement-modal-utils.test.js @@ -267,9 +267,9 @@ describe('Modal Utils', () => { }); describe('createEntitlementsForProducts', () => { - it('creates entitlements for multiple products in parallel', async () => { + it('creates new entitlements when none exist', async () => { const mockSite = { getId: () => 'site123' }; - const mockLambdaContext = { env: {}, log: {} }; + const mockLambdaContext = { env: {}, log: {}, dataAccess: {} }; const selectedProducts = ['ASO', 'LLMO']; const mockEntitlement1 = { getId: () => 'entitlement-aso' }; @@ -279,12 +279,14 @@ describe('Modal Utils', () => { mockTierClient.createForSite .onFirstCall().returns({ + checkValidEntitlement: sinon.stub().resolves({}), createEntitlement: sinon.stub().resolves({ entitlement: mockEntitlement1, siteEnrollment: mockEnrollment1, }), }) .onSecondCall().returns({ + checkValidEntitlement: sinon.stub().resolves({}), createEntitlement: sinon.stub().resolves({ entitlement: mockEntitlement2, siteEnrollment: mockEnrollment2, @@ -311,16 +313,16 @@ describe('Modal Utils', () => { }); }); - it('creates entitlement for single product', async () => { + it('preserves existing entitlement and returns existing enrollment', async () => { const mockSite = { getId: () => 'site456' }; - const mockLambdaContext = { env: {}, log: {} }; + const mockLambdaContext = { env: {}, log: {}, dataAccess: {} }; const selectedProducts = ['ASO']; - const mockEntitlement = { getId: () => 'entitlement-123' }; - const mockEnrollment = { getId: () => 'enrollment-123' }; + const mockEntitlement = { getId: () => 'entitlement-existing', getTier: () => 'PAID' }; + const mockEnrollment = { getId: () => 'enrollment-existing' }; mockTierClient.createForSite.returns({ - createEntitlement: sinon.stub().resolves({ + checkValidEntitlement: sinon.stub().resolves({ entitlement: mockEntitlement, siteEnrollment: mockEnrollment, }), @@ -335,17 +337,60 @@ describe('Modal Utils', () => { expect(results).to.have.lengthOf(1); expect(results[0]).to.deep.equal({ product: 'ASO', - entitlementId: 'entitlement-123', - enrollmentId: 'enrollment-123', + entitlementId: 'entitlement-existing', + enrollmentId: 'enrollment-existing', + existingTier: 'PAID', + alreadyExisted: true, }); }); - it('returns empty array for empty product list', async () => { + it('creates enrollment when entitlement exists but enrollment does not', async () => { const mockSite = { getId: () => 'site789' }; - const mockLambdaContext = { env: {}, log: {} }; + const mockEnrollment = { getId: () => 'enrollment-new' }; + const mockLambdaContext = { + env: {}, + log: {}, + dataAccess: { + SiteEnrollment: { + create: sinon.stub().resolves(mockEnrollment), + }, + }, + }; + const selectedProducts = ['ASO']; + + const mockEntitlement = { getId: () => 'entitlement-existing', getTier: () => 'PLG' }; + + mockTierClient.createForSite.returns({ + checkValidEntitlement: sinon.stub().resolves({ + entitlement: mockEntitlement, + }), + }); + + const results = await modalUtils.createEntitlementsForProducts( + mockLambdaContext, + mockSite, + selectedProducts, + ); + + expect(results).to.have.lengthOf(1); + expect(results[0]).to.deep.equal({ + product: 'ASO', + entitlementId: 'entitlement-existing', + enrollmentId: 'enrollment-new', + existingTier: 'PLG', + enrollmentCreated: true, + }); + expect(mockLambdaContext.dataAccess.SiteEnrollment.create).to.have.been.calledWith({ + siteId: 'site789', + entitlementId: 'entitlement-existing', + }); + }); + + it('returns empty array for empty product list', async () => { + const mockSite = { getId: () => 'site999' }; + const mockLambdaContext = { env: {}, log: {}, dataAccess: {} }; const selectedProducts = []; - // Reset the stub before this test mockTierClient.createForSite.resetHistory(); const results = await modalUtils.createEntitlementsForProducts( @@ -360,7 +405,7 @@ describe('Modal Utils', () => { }); describe('postEntitlementMessages', () => { - it('posts messages for multiple entitlement results', async () => { + it('posts created message for newly created entitlements', async () => { const say = sinon.stub().resolves(); const entitlementResults = [ { product: 'ASO', entitlementId: 'ent-1', enrollmentId: 'enr-1' }, @@ -371,32 +416,51 @@ describe('Modal Utils', () => { await modalUtils.postEntitlementMessages(say, entitlementResults, siteId); expect(say.calledTwice).to.be.true; + expect(say.getCall(0).args[0]).to.include('Created'); expect(say.getCall(0).args[0]).to.include('ASO'); expect(say.getCall(0).args[0]).to.include('ent-1'); expect(say.getCall(0).args[0]).to.include('enr-1'); - expect(say.getCall(0).args[0]).to.include('site123'); expect(say.getCall(0).args[0]).to.include(EntitlementModel.TIERS.FREE_TRIAL); + expect(say.getCall(1).args[0]).to.include('Created'); expect(say.getCall(1).args[0]).to.include('LLMO'); expect(say.getCall(1).args[0]).to.include('ent-2'); expect(say.getCall(1).args[0]).to.include('enr-2'); - expect(say.getCall(1).args[0]).to.include('site123'); }); - it('posts message for single entitlement result', async () => { + it('posts already-existed message when entitlement and enrollment existed', async () => { const say = sinon.stub().resolves(); const entitlementResults = [ - { product: 'ASO', entitlementId: 'ent-123', enrollmentId: 'enr-456' }, + { + product: 'ASO', entitlementId: 'ent-123', enrollmentId: 'enr-456', existingTier: 'PAID', alreadyExisted: true, + }, ]; const siteId = 'site789'; await modalUtils.postEntitlementMessages(say, entitlementResults, siteId); expect(say.calledOnce).to.be.true; - expect(say.getCall(0).args[0]).to.include('ASO'); + expect(say.getCall(0).args[0]).to.include('already exist'); + expect(say.getCall(0).args[0]).to.include('PAID'); expect(say.getCall(0).args[0]).to.include('ent-123'); - expect(say.getCall(0).args[0]).to.include('enr-456'); - expect(say.getCall(0).args[0]).to.include('site789'); + }); + + it('posts enrollment-created message when entitlement existed but enrollment was new', async () => { + const say = sinon.stub().resolves(); + const entitlementResults = [ + { + product: 'ASO', entitlementId: 'ent-123', enrollmentId: 'enr-new', existingTier: 'PLG', enrollmentCreated: true, + }, + ]; + const siteId = 'site789'; + + await modalUtils.postEntitlementMessages(say, entitlementResults, siteId); + + expect(say.calledOnce).to.be.true; + expect(say.getCall(0).args[0]).to.include('already existed'); + expect(say.getCall(0).args[0]).to.include('created enrollment'); + expect(say.getCall(0).args[0]).to.include('PLG'); + expect(say.getCall(0).args[0]).to.include('enr-new'); }); it('does not post messages for empty results', async () => { diff --git a/test/support/slack/actions/entitlement-modals.test.js b/test/support/slack/actions/entitlement-modals.test.js index b44d1ffcf..676d10d8c 100644 --- a/test/support/slack/actions/entitlement-modals.test.js +++ b/test/support/slack/actions/entitlement-modals.test.js @@ -155,6 +155,7 @@ describe('EntitlementModals', () => { revokeSiteEnrollment: sinon.stub().resolves(), }), createForOrg: sinon.stub().returns({ + checkValidEntitlement: sinon.stub().resolves({}), createEntitlement: sinon.stub().resolves({ entitlement: { getId: () => TEST_IDS.ent }, }), @@ -397,15 +398,55 @@ describe('EntitlementModals', () => { ); }); + it('preserves existing entitlement and skips creation', async () => { + const existingEntitlement = { + getId: () => 'ent-existing', + getTier: () => 'PLG', + }; + const mockTierClientInstance = { + checkValidEntitlement: sinon.stub().resolves({ entitlement: existingEntitlement }), + }; + + const module = await esmock('../../../../src/support/slack/actions/entitlement-modals.js', { + '@adobe/spacecat-shared-tier-client': { + default: { + createForOrg: sinon.stub().returns(mockTierClientInstance), + }, + }, + '../../../../src/support/slack/actions/entitlement-modal-utils.js': { + extractSelectedProducts: await import('../../../../src/support/slack/actions/entitlement-modal-utils.js') + .then((m) => m.extractSelectedProducts), + createSayFunction: await import('../../../../src/support/slack/actions/entitlement-modal-utils.js') + .then((m) => m.createSayFunction), + updateMessageToProcessing: mockUpdateMessageToProcessing, + }, + }); + + await testModalSubmission( + module.ensureEntitlementImsOrgModal, + createOrgMetadata(), + createProductState(), + (ack, client) => { + expect(ack).to.have.been.calledOnce; + expect(client.chat.postMessage).to.have.been.calledWith(sinon.match({ + text: sinon.match('already exists'), + })); + expect(client.chat.postMessage).to.have.been.calledWith(sinon.match({ + text: sinon.match('PLG'), + })); + }, + ); + }); + it('handles errors during individual product entitlement', async () => { - const mockTierClient = { - createEntitlement: sinon.stub().rejects(new Error('Tier error')), + const mockTierClientInstance = { + checkValidEntitlement: sinon.stub().rejects(new Error('Tier error')), }; const module = await esmock('../../../../src/support/slack/actions/entitlement-modals.js', { '@adobe/spacecat-shared-tier-client': { default: { - createForOrg: sinon.stub().returns(mockTierClient), + createForOrg: sinon.stub().returns(mockTierClientInstance), }, }, '../../../../src/support/slack/actions/entitlement-modal-utils.js': {