Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
*/

import BaseCollection from '../base/base.collection.js';
import DataAccessError from '../../errors/data-access.error.js';
import { DEFAULT_PAGE_SIZE } from '../../util/postgrest.utils.js';

/**
* EntitlementCollection - A collection class responsible for managing Entitlement entities.
Expand All @@ -22,7 +24,67 @@ import BaseCollection from '../base/base.collection.js';
class EntitlementCollection extends BaseCollection {
static COLLECTION_NAME = 'EntitlementCollection';

// add custom methods here
/**
* Finds all entitlements for a given product code with their parent organization
* data embedded via PostgREST resource embedding (INNER JOIN). This avoids N+1
* queries when you need both entitlement and organization data.
*
* Returns plain objects, not model instances, since the result combines fields
* from two entities. Callers should access properties directly
* (e.g., `result.entitlement.tier`), not via getter methods.
*
* @param {string} productCode - Product code to filter by (e.g., 'LLMO').
* @returns {Promise<Array<{entitlement: {id: string, productCode: string, tier: string},
* organization: {id: string, name: string, imsOrgId: string}}>>}
*/
async allByProductCodeWithOrganization(productCode) {
if (!productCode) {
throw new DataAccessError('productCode is required', { entityName: 'Entitlement', tableName: 'entitlements' });
}

const allResults = [];
let offset = 0;
let keepGoing = true;

while (keepGoing) {
// eslint-disable-next-line no-await-in-loop
const { data, error } = await this.postgrestService
.from('entitlements')
.select('id, product_code, tier, organizations!inner(id, name, ims_org_id)')
.eq('product_code', productCode)
.range(offset, offset + DEFAULT_PAGE_SIZE - 1);

if (error) {
this.log.error(`[Entitlement] Failed to query entitlements with organizations - ${error.message}`, error);
throw new DataAccessError(
'Failed to query entitlements with organizations',
{ entityName: 'Entitlement', tableName: 'entitlements' },
error,
);
}

if (!data || data.length === 0) {
keepGoing = false;
} else {
allResults.push(...data);
keepGoing = data.length >= DEFAULT_PAGE_SIZE;
offset += DEFAULT_PAGE_SIZE;
}
}

return allResults.map((row) => ({
entitlement: {
id: row.id,
productCode: row.product_code,
tier: row.tier,
},
organization: {
id: row.organizations.id,
name: row.organizations.name,
imsOrgId: row.organizations.ims_org_id,
},
}));
}
}

export default EntitlementCollection;
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ export interface Entitlement extends BaseModel {
setQuotas(quotas: object): Entitlement;
}

export interface EntitlementWithOrganization {
entitlement: {
id: string;
productCode: EntitlementProductCode;
tier: EntitlementTier;
};
organization: {
id: string;
name: string;
imsOrgId: string;
} | null;
}

export interface EntitlementCollection extends
BaseCollection<Entitlement> {
allByOrganizationId(organizationId: string): Promise<Entitlement[]>;
Expand All @@ -44,4 +57,8 @@ export interface EntitlementCollection extends
organizationId: string,
productCode: EntitlementProductCode,
): Promise<Entitlement | null>;

allByProductCodeWithOrganization(
productCode: EntitlementProductCode,
): Promise<EntitlementWithOrganization[]>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,66 @@ describe('Entitlement IT', async () => {
);
});

describe('allByProductCodeWithOrganization', () => {
it('returns LLMO entitlements with embedded organization data', async () => {
const results = await Entitlement.allByProductCodeWithOrganization('LLMO');

expect(results).to.be.an('array');
expect(results.length).to.be.greaterThan(0);

for (const { entitlement, organization } of results) {
expect(entitlement).to.be.an('object');
expect(entitlement.id).to.be.a('string');
expect(entitlement.productCode).to.equal('LLMO');
expect(entitlement.tier).to.be.oneOf(['FREE_TRIAL', 'PAID']);

expect(organization).to.be.an('object');
expect(organization.id).to.be.a('string');
expect(organization.name).to.be.a('string');
expect(organization.imsOrgId).to.be.a('string');
}
});

it('returns only entitlements matching the given product code', async () => {
const llmoResults = await Entitlement.allByProductCodeWithOrganization('LLMO');
const asoResults = await Entitlement.allByProductCodeWithOrganization('ASO');

// Verify product code filtering: no cross-contamination
for (const { entitlement } of llmoResults) {
expect(entitlement.productCode).to.equal('LLMO');
}
for (const { entitlement } of asoResults) {
expect(entitlement.productCode).to.equal('ASO');
}

// LLMO should have results (seed data has 2 + 1 created by earlier test)
expect(llmoResults.length).to.be.greaterThanOrEqual(1);

// Verify no ID overlap between product codes
const llmoIds = new Set(llmoResults.map((r) => r.entitlement.id));
for (const { entitlement } of asoResults) {
expect(llmoIds.has(entitlement.id)).to.be.false;
}
});

it('returns correct organization data for each entitlement', async () => {
const results = await Entitlement.allByProductCodeWithOrganization('LLMO');

// Verify that each entitlement's organization has a valid imsOrgId
const orgIds = results.map(({ organization }) => organization.imsOrgId);
for (const imsOrgId of orgIds) {
expect(imsOrgId).to.match(/@AdobeOrg$/);
}
});

it('returns empty array for product code with no entitlements', async () => {
const results = await Entitlement.allByProductCodeWithOrganization('ACO');

expect(results).to.be.an('array');
expect(results).to.have.lengthOf(0);
});
});

it('removes an entitlement', async () => {
const entitlement = await Entitlement.findById(sampleData.entitlements[0].getId());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@

import { expect, use as chaiUse } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';

import DataAccessError from '../../../../src/errors/data-access.error.js';
import Entitlement from '../../../../src/models/entitlement/entitlement.model.js';
import { DEFAULT_PAGE_SIZE } from '../../../../src/util/postgrest.utils.js';

import { createElectroMocks } from '../../util.js';

Expand Down Expand Up @@ -82,4 +85,111 @@ describe('EntitlementCollection', () => {
expect(trialUserActivitiesRef.isRemoveDependents()).to.be.false;
});
});

describe('allByProductCodeWithOrganization', () => {
let rangeStub;

function setupPostgrestChain(result) {
rangeStub = sinon.stub().resolves(result);
const eqStub = sinon.stub().returns({ range: rangeStub });
const selectStub = sinon.stub().returns({ eq: eqStub });
instance.postgrestService.from = sinon.stub().returns({ select: selectStub });
return {
selectStub, eqStub, rangeStub,
};
}

it('returns entitlements with embedded organization data', async () => {
setupPostgrestChain({
data: [
{
id: 'ent-1',
product_code: 'LLMO',
tier: 'PAID',
organizations: { id: 'org-1', name: 'Acme Corp', ims_org_id: 'acme@AdobeOrg' },
},
{
id: 'ent-2',
product_code: 'LLMO',
tier: 'FREE_TRIAL',
organizations: { id: 'org-2', name: 'Beta Inc', ims_org_id: 'beta@AdobeOrg' },
},
],
error: null,
});

const results = await instance.allByProductCodeWithOrganization('LLMO');

expect(results).to.have.lengthOf(2);
expect(results[0]).to.deep.equal({
entitlement: { id: 'ent-1', productCode: 'LLMO', tier: 'PAID' },
organization: { id: 'org-1', name: 'Acme Corp', imsOrgId: 'acme@AdobeOrg' },
});
expect(results[1]).to.deep.equal({
entitlement: { id: 'ent-2', productCode: 'LLMO', tier: 'FREE_TRIAL' },
organization: { id: 'org-2', name: 'Beta Inc', imsOrgId: 'beta@AdobeOrg' },
});
});

it('returns empty array when no entitlements exist', async () => {
setupPostgrestChain({ data: [], error: null });

const results = await instance.allByProductCodeWithOrganization('LLMO');

expect(results).to.deep.equal([]);
});

it('returns empty array when data is null', async () => {
setupPostgrestChain({ data: null, error: null });

const results = await instance.allByProductCodeWithOrganization('LLMO');

expect(results).to.deep.equal([]);
});

it('throws DataAccessError when productCode is missing', async () => {
await expect(instance.allByProductCodeWithOrganization(null))
.to.be.rejectedWith(DataAccessError, 'productCode is required');
await expect(instance.allByProductCodeWithOrganization(''))
.to.be.rejectedWith(DataAccessError, 'productCode is required');
});

it('throws DataAccessError on PostgREST error', async () => {
setupPostgrestChain({ data: null, error: { message: 'connection refused' } });

await expect(instance.allByProductCodeWithOrganization('LLMO'))
.to.be.rejectedWith(DataAccessError, 'Failed to query entitlements with organizations');
expect(mockLogger.error).to.have.been.called;
});

it('paginates when results exceed page size', async () => {
const page1 = Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({
id: `ent-${i}`,
product_code: 'LLMO',
tier: 'PAID',
organizations: { id: `org-${i}`, name: `Org ${i}`, ims_org_id: `org${i}@AdobeOrg` },
}));
const page2 = [{
id: `ent-${DEFAULT_PAGE_SIZE}`,
product_code: 'LLMO',
tier: 'FREE_TRIAL',
organizations: { id: `org-${DEFAULT_PAGE_SIZE}`, name: `Org ${DEFAULT_PAGE_SIZE}`, ims_org_id: `org${DEFAULT_PAGE_SIZE}@AdobeOrg` },
}];

rangeStub = sinon.stub();
rangeStub.onFirstCall().resolves({ data: page1, error: null });
rangeStub.onSecondCall().resolves({ data: page2, error: null });
const eqStub = sinon.stub().returns({ range: rangeStub });
const selectStub = sinon.stub().returns({ eq: eqStub });
instance.postgrestService.from = sinon.stub().returns({ select: selectStub });

const results = await instance.allByProductCodeWithOrganization('LLMO');

expect(results).to.have.lengthOf(DEFAULT_PAGE_SIZE + 1);
expect(rangeStub).to.have.been.calledTwice;
expect(rangeStub.firstCall.args).to.deep.equal([0, DEFAULT_PAGE_SIZE - 1]);
expect(rangeStub.secondCall.args)
.to.deep.equal([DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE * 2 - 1]);
});
});
});