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,7 @@
*/

import BaseCollection from '../base/base.collection.js';
import DataAccessError from '../../errors/data-access.error.js';

/**
* SiteEnrollmentCollection - A class representing a collection of SiteEnrollment entities.
Expand All @@ -22,6 +23,30 @@ import BaseCollection from '../base/base.collection.js';
class SiteEnrollmentCollection extends BaseCollection {
static COLLECTION_NAME = 'SiteEnrollmentCollection';

/**
* Returns all site IDs enrolled in a given product code in a single JOIN query.
*
* @param {string} productCode - Product code to filter by (e.g. 'LLMO').
* @returns {Promise<string[]>} Array of siteId strings.
*/
async allSiteIdsByProductCode(productCode) {
if (!productCode) {
throw new DataAccessError('productCode is required', { entityName: 'SiteEnrollment', tableName: 'site_enrollments' });
}

const { data, error } = await this.postgrestService
.from(this.tableName)
.select('site_id, entitlements!inner(product_code)')
.eq('entitlements.product_code', productCode);

if (error) {
this.log.error(`[SiteEnrollmentCollection] Failed to query site_enrollments by productCode - ${error.message}`, error);
throw new DataAccessError('Failed to query site_enrollments by productCode', { entityName: 'SiteEnrollment', tableName: 'site_enrollments' }, error);
}

return (data || []).map((row) => row.site_id);
}

async create(item, options = {}) {
if (item?.siteId && item?.entitlementId) {
const existing = await this.findByIndexKeys({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,35 @@ class SiteCollection extends BaseCollection {
return this.allByProjectId(projectId);
}

/**
* Returns all sites enrolled in a given product (e.g. 'LLMO', 'ASO').
* Uses entityRegistry to chain through EntitlementCollection and SiteEnrollmentCollection,
* then batch-fetches full Site objects.
*
* @param {string} productCode - Product code to filter by (e.g. 'LLMO').
* @returns {Promise<Site[]>}
*/
async allByEnrollmentProductCode(productCode, options = {}) {
if (!hasText(productCode)) {
throw new DataAccessError('productCode is required', this);
}

const siteEnrollmentCollection = this.entityRegistry.getCollection('SiteEnrollmentCollection');

// Query 1: get all site IDs enrolled in the given product (single JOIN query)
const siteIds = await siteEnrollmentCollection.allSiteIdsByProductCode(productCode);
if (siteIds.length === 0) {
return [];
}

// Query 2: batch-fetch Site objects (caller controls which fields to fetch)
const { data: sites } = await this.batchGetByKeys(
siteIds.map((siteId) => ({ siteId })),
options,
);
return sites;
}

async allByOrganizationIdAndProjectName(organizationId, projectName) {
if (!hasText(organizationId)) {
throw new DataAccessError('organizationId is required', this);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,73 @@ describe('SiteEnrollmentCollection', () => {
expect(superCreateStub).to.have.been.calledOnceWithExactly(mockRecord, { upsert: true });
});
});

describe('allSiteIdsByProductCode', () => {
let fromStub;
let selectStub;
let eqStub;

beforeEach(() => {
eqStub = sinon.stub();
selectStub = sinon.stub().returns({ eq: eqStub });
fromStub = sinon.stub().returns({ select: selectStub });
instance.postgrestService.from = fromStub;
});

afterEach(() => {
sinon.restore();
});

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

it('returns array of site IDs for a matching product code', async () => {
eqStub.resolves({
data: [
{ site_id: 'cfa88998-a0a0-4136-b21d-0ff2aa127443' },
{ site_id: 'd1e2f3a4-b5c6-7890-abcd-ef1234567890' },
],
error: null,
});

const result = await instance.allSiteIdsByProductCode('LLMO');

expect(result).to.deep.equal([
'cfa88998-a0a0-4136-b21d-0ff2aa127443',
'd1e2f3a4-b5c6-7890-abcd-ef1234567890',
]);
expect(fromStub).to.have.been.calledOnceWithExactly('site_enrollments');
expect(selectStub).to.have.been.calledOnceWithExactly('site_id, entitlements!inner(product_code)');
expect(eqStub).to.have.been.calledOnceWithExactly('entitlements.product_code', 'LLMO');
});

it('returns empty array when no enrollments match', async () => {
eqStub.resolves({ data: [], error: null });

const result = await instance.allSiteIdsByProductCode('LLMO');

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

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

const result = await instance.allSiteIdsByProductCode('LLMO');

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

it('logs error and throws DataAccessError when query fails', async () => {
const dbError = new Error('DB connection failed');
eqStub.resolves({ data: null, error: dbError });

await expect(instance.allSiteIdsByProductCode('LLMO'))
.to.be.rejectedWith('Failed to query site_enrollments by productCode');

expect(mockLogger.error).to.have.been.called;
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -393,4 +393,68 @@ describe('SiteCollection', () => {
});
});
});

describe('allByEnrollmentProductCode', () => {
let mockSiteEnrollmentCollection;

beforeEach(() => {
mockSiteEnrollmentCollection = {
allSiteIdsByProductCode: stub(),
};
mockEntityRegistry.getCollection = stub()
.withArgs('SiteEnrollmentCollection')
.returns(mockSiteEnrollmentCollection);
});

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

it('returns empty array and does not call batchGetByKeys when no site IDs found', async () => {
mockSiteEnrollmentCollection.allSiteIdsByProductCode.resolves([]);
instance.batchGetByKeys = stub();

const result = await instance.allByEnrollmentProductCode('LLMO');

expect(result).to.deep.equal([]);
expect(mockSiteEnrollmentCollection.allSiteIdsByProductCode).to.have.been.calledOnceWithExactly('LLMO');
expect(instance.batchGetByKeys).to.not.have.been.called;
});

it('returns sites fetched by batchGetByKeys with default empty options', async () => {
const siteIds = ['cfa88998-a0a0-4136-b21d-0ff2aa127443', 'd1e2f3a4-b5c6-7890-abcd-ef1234567890'];
const mockSites = [{ getId: () => siteIds[0] }, { getId: () => siteIds[1] }];
mockSiteEnrollmentCollection.allSiteIdsByProductCode.resolves(siteIds);
instance.batchGetByKeys = stub().resolves({ data: mockSites });

const result = await instance.allByEnrollmentProductCode('LLMO');

expect(result).to.deep.equal(mockSites);
expect(instance.batchGetByKeys).to.have.been.calledOnceWithExactly(
[
{ siteId: 'cfa88998-a0a0-4136-b21d-0ff2aa127443' },
{ siteId: 'd1e2f3a4-b5c6-7890-abcd-ef1234567890' },
],
{},
);
});

it('passes caller-supplied options through to batchGetByKeys', async () => {
const siteIds = ['cfa88998-a0a0-4136-b21d-0ff2aa127443'];
const mockSites = [{ getId: () => siteIds[0] }];
const options = { attributes: ['siteId', 'baseURL', 'config'] };
mockSiteEnrollmentCollection.allSiteIdsByProductCode.resolves(siteIds);
instance.batchGetByKeys = stub().resolves({ data: mockSites });

const result = await instance.allByEnrollmentProductCode('LLMO', options);

expect(result).to.deep.equal(mockSites);
expect(instance.batchGetByKeys).to.have.been.calledOnceWithExactly(
[{ siteId: 'cfa88998-a0a0-4136-b21d-0ff2aa127443' }],
options,
);
});
});
});
Loading