diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 9a24380e2..c2ebd111c 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -10,6 +10,7 @@ on: [push] env: CI_BUILD_NUM: ${{ github.run_id }} CI_BRANCH: ${{ github.ref_name }} + NODE_OPTIONS: --max-old-space-size=4096 jobs: test: diff --git a/packages/spacecat-shared-data-access/src/models/audit-url/audit-url.collection.js b/packages/spacecat-shared-data-access/src/models/audit-url/audit-url.collection.js new file mode 100644 index 000000000..64fb42cbb --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/audit-url/audit-url.collection.js @@ -0,0 +1,241 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { hasText } from '@adobe/spacecat-shared-utils'; + +import BaseCollection from '../base/base.collection.js'; + +/** + * AuditUrlCollection - A collection class responsible for managing AuditUrl entities. + * Extends the BaseCollection to provide specific methods for interacting with AuditUrl records. + * + * @class AuditUrlCollection + * @extends BaseCollection + */ +class AuditUrlCollection extends BaseCollection { + /** + * Sorts audit URLs by a specified field. + * @param {Array} auditUrls - Array of AuditUrl objects to sort. + * @param {string} sortBy - Field to sort by ('url', 'createdAt', 'updatedAt'). + * @param {string} sortOrder - Sort order ('asc' or 'desc'). Default: 'asc'. + * @returns {Array} Sorted array of AuditUrl objects. + * @private + */ + static sortAuditUrls(auditUrls, sortBy = 'createdAt', sortOrder = 'asc') { + if (!auditUrls || auditUrls.length === 0) { + return auditUrls; + } + + const sorted = [...auditUrls].sort((a, b) => { + let aValue; + let bValue; + + // Get values using getter methods if available (with optional chaining) + switch (sortBy) { + case 'url': + aValue = a.getUrl?.() ?? a.url; + bValue = b.getUrl?.() ?? b.url; + break; + case 'createdAt': + aValue = a.getCreatedAt?.() ?? a.createdAt; + bValue = b.getCreatedAt?.() ?? b.createdAt; + break; + case 'updatedAt': + aValue = a.getUpdatedAt?.() ?? a.updatedAt; + bValue = b.getUpdatedAt?.() ?? b.updatedAt; + break; + default: + return 0; + } + + // Handle null/undefined values (push to end) + if (aValue == null && bValue == null) return 0; + if (aValue == null) return 1; + if (bValue == null) return -1; + + // Compare values + let comparison = 0; + if (typeof aValue === 'string' && typeof bValue === 'string') { + comparison = aValue.localeCompare(bValue); + } else if (aValue < bValue) { + comparison = -1; + } else if (aValue > bValue) { + comparison = 1; + } else { + comparison = 0; + } + + return sortOrder === 'desc' ? -comparison : comparison; + }); + + return sorted; + } + + /** + * Finds an audit URL by site ID and URL. + * This is a convenience method for looking up a specific URL. + * + * @param {string} siteId - The site ID. + * @param {string} url - The URL to find. + * @returns {Promise} The found AuditUrl or null. + */ + async findBySiteIdAndUrl(siteId, url) { + if (!hasText(siteId) || !hasText(url)) { + throw new Error('Both siteId and url are required'); + } + + const results = await this.allBySiteIdAndUrl(siteId, url); + return results.length > 0 ? results[0] : null; + } + + /** + * Gets all audit URLs for a site that have a specific audit type enabled. + * Note: This performs filtering after retrieval since audits is a list. + * + * @param {string} siteId - The site ID. + * @param {string} auditType - The audit type to filter by. + * @param {object} [options={}] - Query options (limit, cursor, sortBy, sortOrder). + * @returns {Promise<{items: AuditUrl[], cursor?: string}>} Paginated results. + */ + async allBySiteIdAndAuditType(siteId, auditType, options = {}) { + if (!hasText(siteId) || !hasText(auditType)) { + throw new Error('Both siteId and auditType are required'); + } + + const { sortBy, sortOrder, ...queryOptions } = options; + + // Get all URLs for the site + const allUrls = await this.allBySiteId(siteId, queryOptions); + + // Filter by audit type + let filtered = allUrls.filter((auditUrl) => auditUrl.isAuditEnabled(auditType)); + + // Apply sorting if requested + if (sortBy) { + filtered = AuditUrlCollection.sortAuditUrls(filtered, sortBy, sortOrder); + } + + return filtered; + } + + /** + * Gets all audit URLs for a site with sorting support. + * @param {string} siteId - The site ID. + * @param {object} [options={}] - Query options (limit, cursor, sortBy, sortOrder). + * @returns {Promise<{items: AuditUrl[], cursor?: string}>} Paginated and sorted results. + */ + async allBySiteIdSorted(siteId, options = {}) { + if (!hasText(siteId)) { + throw new Error('SiteId is required'); + } + + const { sortBy, sortOrder, ...queryOptions } = options; + + // Get all URLs for the site + const result = await this.allBySiteId(siteId, queryOptions); + + // Handle both array and paginated result formats + const items = Array.isArray(result) ? result : (result.items || []); + + // Apply sorting if requested + const sortedItems = sortBy + ? AuditUrlCollection.sortAuditUrls(items, sortBy, sortOrder) : items; + + // Return in the same format as received + if (Array.isArray(result)) { + return sortedItems; + } + + return { + items: sortedItems, + cursor: result.cursor, + }; + } + + /** + * Gets all audit URLs for a site by byCustomer flag with sorting support. + * @param {string} siteId - The site ID. + * @param {boolean} byCustomer - True for customer-added, false for system-added. + * @param {object} [options={}] - Query options (limit, cursor, sortBy, sortOrder). + * @returns {Promise<{items: AuditUrl[], cursor?: string}>} Paginated and sorted results. + */ + async allBySiteIdByCustomerSorted(siteId, byCustomer, options = {}) { + if (!hasText(siteId) || typeof byCustomer !== 'boolean') { + throw new Error('SiteId is required and byCustomer must be a boolean'); + } + + const { sortBy, sortOrder, ...queryOptions } = options; + + // Get all URLs for the site and byCustomer flag + const result = await this.allBySiteIdByCustomer(siteId, byCustomer, queryOptions); + + // Handle both array and paginated result formats + const items = Array.isArray(result) ? result : (result.items || []); + + // Apply sorting if requested + const sortedItems = sortBy + ? AuditUrlCollection.sortAuditUrls(items, sortBy, sortOrder) : items; + + // Return in the same format as received + if (Array.isArray(result)) { + return sortedItems; + } + + return { + items: sortedItems, + cursor: result.cursor, + }; + } + + /** + * Removes all audit URLs for a specific site. + * Useful for cleanup operations. + * + * @param {string} siteId - The site ID. + * @returns {Promise} + */ + async removeForSiteId(siteId) { + if (!hasText(siteId)) { + throw new Error('SiteId is required'); + } + + const urlsToRemove = await this.allBySiteId(siteId); + const idsToRemove = urlsToRemove.map((auditUrl) => auditUrl.getId()); + + if (idsToRemove.length > 0) { + await this.removeByIds(idsToRemove); + } + } + + /** + * Removes audit URLs by byCustomer flag for a specific site. + * For example, remove all customer-added or all system-added URLs. + * + * @param {string} siteId - The site ID. + * @param {boolean} byCustomer - True for customer-added, false for system-added. + * @returns {Promise} + */ + async removeForSiteIdByCustomer(siteId, byCustomer) { + if (!hasText(siteId) || typeof byCustomer !== 'boolean') { + throw new Error('SiteId is required and byCustomer must be a boolean'); + } + + const urlsToRemove = await this.allBySiteIdByCustomer(siteId, byCustomer); + const idsToRemove = urlsToRemove.map((auditUrl) => auditUrl.getId()); + + if (idsToRemove.length > 0) { + await this.removeByIds(idsToRemove); + } + } +} + +export default AuditUrlCollection; diff --git a/packages/spacecat-shared-data-access/src/models/audit-url/audit-url.model.js b/packages/spacecat-shared-data-access/src/models/audit-url/audit-url.model.js new file mode 100644 index 000000000..7ef08588e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/audit-url/audit-url.model.js @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +/** + * AuditUrl - A class representing an AuditUrl entity. + * Provides methods to access and manipulate AuditUrl-specific data. + * + * @class AuditUrl + * @extends BaseModel + */ +class AuditUrl extends BaseModel { + /** + * Checks if this URL is enabled for a specific audit type. + * @param {string} auditType - The audit type to check. + * @returns {boolean} True if the audit is enabled for this URL. + */ + isAuditEnabled(auditType) { + const audits = this.getAudits?.() ?? this.audits ?? []; + return audits.includes(auditType); + } + + /** + * Adds an audit type to the audits array if not already present. + * @param {string} auditType - The audit type to add. + * @returns {this} The current instance for chaining. + */ + enableAudit(auditType) { + const audits = this.getAudits?.() ?? this.audits ?? []; + if (!audits.includes(auditType)) { + // Create a new array instead of mutating the existing one + const updatedAudits = [...audits, auditType]; + if (this.setAudits) { + this.setAudits(updatedAudits); + } else { + this.audits = updatedAudits; + } + } + return this; + } + + /** + * Removes an audit type from the audits array. + * @param {string} auditType - The audit type to remove. + * @returns {this} The current instance for chaining. + */ + disableAudit(auditType) { + const audits = this.getAudits?.() ?? this.audits ?? []; + // filter() already creates a new array + const filtered = audits.filter((a) => a !== auditType); + if (this.setAudits) { + this.setAudits(filtered); + } else { + this.audits = filtered; + } + return this; + } + + /** + * Checks if this URL was added by a customer. + * @returns {boolean} True if the URL was added by a customer. + */ + isCustomerUrl() { + const byCustomer = this.getByCustomer?.() ?? this.byCustomer; + return byCustomer === true; + } +} + +export default AuditUrl; diff --git a/packages/spacecat-shared-data-access/src/models/audit-url/audit-url.schema.js b/packages/spacecat-shared-data-access/src/models/audit-url/audit-url.schema.js new file mode 100644 index 000000000..0ffefa1e5 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/audit-url/audit-url.schema.js @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isValidUrl } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import AuditUrl from './audit-url.model.js'; +import AuditUrlCollection from './audit-url.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + +Data Access Patterns: +1. Get all URLs for a site: allBySiteId(siteId) +2. Get all URLs for a site by byCustomer: allBySiteIdByCustomer(siteId, byCustomer) +3. Get a specific URL: findBySiteIdAndUrl(siteId, url) - uses composite primary key +4. Get URLs by audit type: allBySiteIdAndAuditType(siteId, auditType) - filtered in code + +Primary Key: +- PK: siteId - partition key +- SK: url - sort key +This makes siteId + url the natural composite primary key (no separate auditUrlId needed). + +GSI Indexes: +- bySiteIdByCustomer: siteId + byCustomer - for querying by customer vs system added +*/ + +const schema = new SchemaBuilder(AuditUrl, AuditUrlCollection) + .withPrimaryPartitionKeys(['siteId']) + .withPrimarySortKeys(['url']) + .addReference('belongs_to', 'Site', ['url']) + .addAttribute('url', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('byCustomer', { + type: 'boolean', + required: true, + default: true, + }) + .addAttribute('audits', { + type: 'set', + items: 'string', + required: true, + default: [], + }) + .addAttribute('createdBy', { + type: 'string', + required: true, + readOnly: true, + default: 'system', + }) + // Add a GSI for querying by siteId and byCustomer + .addIndex( + { composite: ['siteId'] }, + { composite: ['byCustomer'] }, + ); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/models/audit-url/index.d.ts b/packages/spacecat-shared-data-access/src/models/audit-url/index.d.ts new file mode 100644 index 000000000..61f78a887 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/audit-url/index.d.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel, Site } from '../index'; + +/** + * AuditUrl entity representing a URL to be audited for a site. + * Primary key is composite: siteId (PK) + url (SK) + */ +export interface AuditUrl extends BaseModel { + getAudits(): string[]; + getCreatedAt(): string; + getCreatedBy(): string; + getUpdatedAt(): string; + getUpdatedBy(): string; + getSite(): Promise; + getSiteId(): string; + getByCustomer(): boolean; + getUrl(): string; + setAudits(audits: string[]): AuditUrl; + setSiteId(siteId: string): AuditUrl; + setByCustomer(byCustomer: boolean): AuditUrl; + setUrl(url: string): AuditUrl; + setUpdatedBy(updatedBy: string): AuditUrl; + isAuditEnabled(auditType: string): boolean; + enableAudit(auditType: string): AuditUrl; + disableAudit(auditType: string): AuditUrl; + isCustomerUrl(): boolean; +} + +export interface AuditUrlCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteIdByCustomer(siteId: string, byCustomer: boolean): Promise; + allBySiteIdByCustomerAndUrl(siteId: string, byCustomer: boolean, url: string): Promise; + allBySiteIdAndUrl(siteId: string, url: string): Promise; + allBySiteIdSorted(siteId: string, options?: { limit?: number; cursor?: string; sortBy?: string; sortOrder?: string }): Promise<{ items: AuditUrl[]; cursor?: string }>; + allBySiteIdByCustomerSorted(siteId: string, byCustomer: boolean, options?: { limit?: number; cursor?: string; sortBy?: string; sortOrder?: string }): Promise<{ items: AuditUrl[]; cursor?: string }>; + findBySiteIdAndUrl(siteId: string, url: string): Promise; + allBySiteIdAndAuditType(siteId: string, auditType: string, options?: { limit?: number; cursor?: string; sortBy?: string; sortOrder?: string }): Promise; + removeForSiteId(siteId: string): Promise; + removeForSiteIdByCustomer(siteId: string, byCustomer: boolean): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/models/audit-url/index.js b/packages/spacecat-shared-data-access/src/models/audit-url/index.js new file mode 100644 index 000000000..013ba5b1f --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/audit-url/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import AuditUrl from './audit-url.model.js'; +import AuditUrlCollection from './audit-url.collection.js'; + +export { + AuditUrl, + AuditUrlCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js index c776d200d..251bcfcd5 100755 --- a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js +++ b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js @@ -16,6 +16,7 @@ import { collectionNameToEntityName, decapitalize } from '../../util/util.js'; import ApiKeyCollection from '../api-key/api-key.collection.js'; import AsyncJobCollection from '../async-job/async-job.collection.js'; import AuditCollection from '../audit/audit.collection.js'; +import AuditUrlCollection from '../audit-url/audit-url.collection.js'; import ConfigurationCollection from '../configuration/configuration.collection.js'; import ExperimentCollection from '../experiment/experiment.collection.js'; import EntitlementCollection from '../entitlement/entitlement.collection.js'; @@ -45,6 +46,7 @@ import PageCitabilityCollection from '../page-citability/page-citability.collect import ApiKeySchema from '../api-key/api-key.schema.js'; import AsyncJobSchema from '../async-job/async-job.schema.js'; import AuditSchema from '../audit/audit.schema.js'; +import AuditUrlSchema from '../audit-url/audit-url.schema.js'; import ConfigurationSchema from '../configuration/configuration.schema.js'; import EntitlementSchema from '../entitlement/entitlement.schema.js'; import FixEntitySchema from '../fix-entity/fix-entity.schema.js'; @@ -143,6 +145,7 @@ class EntityRegistry { EntityRegistry.registerEntity(ApiKeySchema, ApiKeyCollection); EntityRegistry.registerEntity(AsyncJobSchema, AsyncJobCollection); EntityRegistry.registerEntity(AuditSchema, AuditCollection); +EntityRegistry.registerEntity(AuditUrlSchema, AuditUrlCollection); EntityRegistry.registerEntity(ConfigurationSchema, ConfigurationCollection); EntityRegistry.registerEntity(EntitlementSchema, EntitlementCollection); EntityRegistry.registerEntity(FixEntitySchema, FixEntityCollection); diff --git a/packages/spacecat-shared-data-access/src/models/index.js b/packages/spacecat-shared-data-access/src/models/index.js index cbd2e354e..76112cda3 100755 --- a/packages/spacecat-shared-data-access/src/models/index.js +++ b/packages/spacecat-shared-data-access/src/models/index.js @@ -13,6 +13,7 @@ export * from './api-key/index.js'; export * from './async-job/index.js'; export * from './audit/index.js'; +export * from './audit-url/index.js'; export * from './base/index.js'; export * from './configuration/index.js'; export * from './entitlement/index.js'; diff --git a/packages/spacecat-shared-data-access/test/fixtures/audit-urls.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/audit-urls.fixture.js new file mode 100644 index 000000000..45ed472a4 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/audit-urls.fixture.js @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const auditUrls = [ + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', // site 0 + url: 'https://example0.com/page-1', + byCustomer: true, + audits: ['accessibility', 'broken-backlinks'], + createdAt: '2025-10-27T12:00:00.000Z', + createdBy: 'user@example.com', + }, + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', // site 0 + url: 'https://example0.com/page-2', + byCustomer: false, + audits: ['accessibility'], + createdAt: '2025-10-27T12:00:00.000Z', + createdBy: 'system', + }, + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', // site 0 + url: 'https://example0.com/page-3', + byCustomer: true, + audits: ['broken-backlinks', 'lhs-mobile'], + createdAt: '2025-10-27T12:00:00.000Z', + createdBy: 'user@example.com', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', // site 1 + url: 'https://example1.com/page-1', + byCustomer: true, + audits: ['accessibility', 'lhs-mobile'], + createdAt: '2025-10-27T12:00:00.000Z', + createdBy: 'admin@example.com', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', // site 1 + url: 'https://example1.com/page-2', + byCustomer: false, + audits: [], + createdAt: '2025-10-27T12:00:00.000Z', + createdBy: 'system', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', // site 2 + url: 'https://example2.com/page-1', + byCustomer: true, + audits: ['accessibility'], + createdAt: '2025-10-27T12:00:00.000Z', + createdBy: 'user@example.com', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', // site 2 + url: 'https://example2.com/assets/document.pdf', + byCustomer: true, + audits: ['broken-backlinks'], + createdAt: '2025-10-27T12:00:00.000Z', + createdBy: 'user@example.com', + }, +]; + +export default auditUrls; diff --git a/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js index f3d888202..fa21745cd 100644 --- a/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js +++ b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js @@ -13,6 +13,7 @@ import apiKeys from './api-keys.fixtures.js'; import asyncJobs from './async-jobs.fixture.js'; import audits from './audits.fixture.js'; +import auditUrls from './audit-urls.fixture.js'; import configurations from './configurations.fixture.js'; import experiments from './experiments.fixture.js'; import importJobs from './import-jobs.fixture.js'; @@ -42,6 +43,7 @@ export default { apiKeys, asyncJobs, audits, + auditUrls, configurations, experiments, fixEntities, diff --git a/packages/spacecat-shared-data-access/test/it/audit-url/audit-url.test.js b/packages/spacecat-shared-data-access/test/it/audit-url/audit-url.test.js new file mode 100644 index 000000000..51becb3ff --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/audit-url/audit-url.test.js @@ -0,0 +1,301 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +function checkAuditUrl(auditUrl) { + expect(auditUrl).to.be.an('object'); + expect(auditUrl.getSiteId()).to.be.a('string'); + expect(auditUrl.getUrl()).to.be.a('string'); + expect(auditUrl.getByCustomer()).to.be.a('boolean'); + expect(auditUrl.getAudits()).to.be.an('array'); + expect(auditUrl.getCreatedAt()).to.be.a('string'); + expect(auditUrl.getCreatedBy()).to.be.a('string'); +} + +describe('AuditUrl IT', async () => { + let sampleData; + let AuditUrl; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + AuditUrl = dataAccess.AuditUrl; + }); + + it('gets all audit URLs for a site', async () => { + const site = sampleData.sites[0]; + + const auditUrls = await AuditUrl.allBySiteId(site.getId()); + + expect(auditUrls).to.be.an('array'); + expect(auditUrls.length).to.equal(3); + + auditUrls.forEach((auditUrl) => { + checkAuditUrl(auditUrl); + expect(auditUrl.getSiteId()).to.equal(site.getId()); + }); + }); + + it('gets all audit URLs for a site by byCustomer flag', async () => { + const site = sampleData.sites[0]; + const byCustomer = true; + + const auditUrls = await AuditUrl.allBySiteIdByCustomer(site.getId(), byCustomer); + + expect(auditUrls).to.be.an('array'); + expect(auditUrls.length).to.equal(2); + + auditUrls.forEach((auditUrl) => { + checkAuditUrl(auditUrl); + expect(auditUrl.getSiteId()).to.equal(site.getId()); + expect(auditUrl.getByCustomer()).to.equal(byCustomer); + }); + }); + + it('finds an audit URL by site ID and URL (composite primary key)', async () => { + const site = sampleData.sites[0]; + const url = 'https://example0.com/page-1'; + + const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), url); + + expect(auditUrl).to.be.an('object'); + checkAuditUrl(auditUrl); + expect(auditUrl.getSiteId()).to.equal(site.getId()); + expect(auditUrl.getUrl()).to.equal(url); + }); + + it('returns null when audit URL not found', async () => { + const site = sampleData.sites[0]; + const url = 'https://example0.com/nonexistent'; + + const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), url); + + expect(auditUrl).to.be.null; + }); + + it('creates a new audit URL', async () => { + const site = sampleData.sites[0]; + const data = { + siteId: site.getId(), + url: 'https://example0.com/new-page', + byCustomer: true, + audits: ['accessibility', 'broken-backlinks'], + createdBy: 'test@example.com', + }; + + const auditUrl = await AuditUrl.create(data); + + checkAuditUrl(auditUrl); + expect(auditUrl.getSiteId()).to.equal(data.siteId); + expect(auditUrl.getUrl()).to.equal(data.url); + expect(auditUrl.getByCustomer()).to.equal(data.byCustomer); + expect(auditUrl.getAudits()).to.deep.equal(data.audits); + expect(auditUrl.getCreatedBy()).to.equal(data.createdBy); + }); + + it('creates an audit URL with default values', async () => { + const site = sampleData.sites[0]; + const data = { + siteId: site.getId(), + url: 'https://example0.com/default-page', + createdBy: 'test@example.com', + }; + + const auditUrl = await AuditUrl.create(data); + + checkAuditUrl(auditUrl); + expect(auditUrl.getByCustomer()).to.equal(true); // Default + expect(auditUrl.getAudits()).to.deep.equal([]); // Default + }); + + it('updates an audit URL', async () => { + const site = sampleData.sites[0]; + const url = 'https://example0.com/page-1'; + const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), url); + + auditUrl.setAudits(['accessibility']); + auditUrl.setUpdatedBy('updater@example.com'); + + const updated = await auditUrl.save(); + + expect(updated.getAudits()).to.deep.equal(['accessibility']); + expect(updated.getUpdatedBy()).to.equal('updater@example.com'); + }); + + it('removes an audit URL', async () => { + const site = sampleData.sites[0]; + const data = { + siteId: site.getId(), + url: 'https://example0.com/to-delete', + byCustomer: true, + audits: ['accessibility'], + createdBy: 'test@example.com', + }; + + const auditUrl = await AuditUrl.create(data); + const siteId = auditUrl.getSiteId(); + const url = auditUrl.getUrl(); + + await auditUrl.remove(); + + const deleted = await AuditUrl.findBySiteIdAndUrl(siteId, url); + expect(deleted).to.be.null; + }); + + describe('Custom Methods', () => { + it('checks if an audit is enabled', async () => { + const site = sampleData.sites[0]; + const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); + + expect(auditUrl.isAuditEnabled('accessibility')).to.be.true; + expect(auditUrl.isAuditEnabled('lhs-mobile')).to.be.false; + }); + + it('enables an audit', async () => { + const site = sampleData.sites[0]; + const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); + const originalAudits = auditUrl.getAudits(); + + auditUrl.enableAudit('lhs-mobile'); + + expect(auditUrl.getAudits()).to.include('lhs-mobile'); + expect(auditUrl.getAudits().length).to.equal(originalAudits.length + 1); + }); + + it('does not duplicate audits when enabling', async () => { + const site = sampleData.sites[0]; + const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); + const originalLength = auditUrl.getAudits().length; + + auditUrl.enableAudit('accessibility'); // Already enabled + + expect(auditUrl.getAudits().length).to.equal(originalLength); + }); + + it('disables an audit', async () => { + const site = sampleData.sites[0]; + const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); + + auditUrl.disableAudit('accessibility'); + + expect(auditUrl.getAudits()).to.not.include('accessibility'); + }); + + it('checks if URL is customer-added', async () => { + const site = sampleData.sites[0]; + const customerUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); + const systemUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-2'); + + expect(customerUrl.isCustomerUrl()).to.be.true; + expect(systemUrl.isCustomerUrl()).to.be.false; + }); + }); + + describe('Collection Methods', () => { + it('gets all audit URLs by audit type', async () => { + const site = sampleData.sites[0]; + + const auditUrls = await AuditUrl.allBySiteIdAndAuditType( + site.getId(), + 'accessibility', + ); + + expect(auditUrls).to.be.an('array'); + // Fixture has 2 URLs with 'accessibility', but "creates a new audit URL" test adds 1 more + expect(auditUrls.length).to.equal(3); + + auditUrls.forEach((auditUrl) => { + expect(auditUrl.isAuditEnabled('accessibility')).to.be.true; + }); + }); + + it('removes all audit URLs for a site', async () => { + const site = sampleData.sites[2]; + + // Verify URLs exist + let auditUrls = await AuditUrl.allBySiteId(site.getId()); + expect(auditUrls.length).to.be.greaterThan(0); + + // Remove all + await AuditUrl.removeForSiteId(site.getId()); + + // Verify removed + auditUrls = await AuditUrl.allBySiteId(site.getId()); + expect(auditUrls.length).to.equal(0); + }); + + it('removes audit URLs by byCustomer flag', async () => { + const site = sampleData.sites[0]; + + // Remove all customer-added URLs + await AuditUrl.removeForSiteIdByCustomer(site.getId(), true); + + // Verify only system-added URLs remain + const auditUrls = await AuditUrl.allBySiteId(site.getId()); + auditUrls.forEach((auditUrl) => { + expect(auditUrl.getByCustomer()).to.equal(false); + }); + }); + }); + + describe('Validation', () => { + it('rejects invalid UUID for siteId', async () => { + const data = { + siteId: 'invalid-uuid', + url: 'https://example.com/page', + createdBy: 'test@example.com', + }; + + await expect(AuditUrl.create(data)).to.be.rejected; + }); + + it('rejects invalid URL format', async () => { + const site = sampleData.sites[0]; + const data = { + siteId: site.getId(), + url: 'not-a-valid-url', + createdBy: 'test@example.com', + }; + + await expect(AuditUrl.create(data)).to.be.rejected; + }); + + it('requires siteId', async () => { + const data = { + url: 'https://example.com/page', + createdBy: 'test@example.com', + }; + + await expect(AuditUrl.create(data)).to.be.rejected; + }); + + it('requires url', async () => { + const site = sampleData.sites[0]; + const data = { + siteId: site.getId(), + createdBy: 'test@example.com', + }; + + await expect(AuditUrl.create(data)).to.be.rejected; + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/audit-url/audit-url.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/audit-url/audit-url.collection.test.js new file mode 100644 index 000000000..6f8d3a147 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/audit-url/audit-url.collection.test.js @@ -0,0 +1,433 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import AuditUrl from '../../../../src/models/audit-url/audit-url.model.js'; +import AuditUrlCollection from '../../../../src/models/audit-url/audit-url.collection.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('AuditUrlCollection', () => { + let instance; + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + auditUrlId: 'au12345', + siteId: 'site12345', + url: 'https://example.com/page', + byCustomer: true, + audits: ['accessibility'], + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(AuditUrl, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the AuditUrlCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + expect(model).to.be.an('object'); + }); + }); + + describe('findBySiteIdAndUrl', () => { + it('throws an error if siteId is not provided', async () => { + await expect(instance.findBySiteIdAndUrl()).to.be.rejectedWith('Both siteId and url are required'); + }); + + it('throws an error if url is not provided', async () => { + await expect(instance.findBySiteIdAndUrl('site123')).to.be.rejectedWith('Both siteId and url are required'); + }); + + it('returns the audit URL when found', async () => { + instance.allBySiteIdAndUrl = stub().resolves([model]); + + const result = await instance.findBySiteIdAndUrl('site123', 'https://example.com/page'); + + expect(result).to.equal(model); + expect(instance.allBySiteIdAndUrl).to.have.been.calledOnceWith('site123', 'https://example.com/page'); + }); + + it('returns null when audit URL is not found', async () => { + instance.allBySiteIdAndUrl = stub().resolves([]); + + const result = await instance.findBySiteIdAndUrl('site123', 'https://example.com/page'); + + expect(result).to.be.null; + }); + }); + + describe('allBySiteIdAndAuditType', () => { + it('throws an error if siteId is not provided', async () => { + await expect(instance.allBySiteIdAndAuditType()).to.be.rejectedWith('Both siteId and auditType are required'); + }); + + it('throws an error if auditType is not provided', async () => { + await expect(instance.allBySiteIdAndAuditType('site123')).to.be.rejectedWith('Both siteId and auditType are required'); + }); + + it('filters URLs by audit type', async () => { + const mockModel1 = Object.create(AuditUrl.prototype); + mockModel1.audits = ['accessibility', 'seo']; + mockModel1.isAuditEnabled = (type) => mockModel1.audits.includes(type); + + const mockModel2 = Object.create(AuditUrl.prototype); + mockModel2.audits = ['broken-backlinks']; + mockModel2.isAuditEnabled = (type) => mockModel2.audits.includes(type); + + const mockModel3 = Object.create(AuditUrl.prototype); + mockModel3.audits = ['accessibility']; + mockModel3.isAuditEnabled = (type) => mockModel3.audits.includes(type); + + instance.allBySiteId = stub().resolves([mockModel1, mockModel2, mockModel3]); + + const result = await instance.allBySiteIdAndAuditType('site123', 'accessibility'); + + expect(result).to.be.an('array'); + expect(result).to.have.length(2); + expect(result).to.include(mockModel1); + expect(result).to.include(mockModel3); + expect(result).to.not.include(mockModel2); + }); + + it('returns empty array when no URLs match the audit type', async () => { + const mockModel = Object.create(AuditUrl.prototype); + mockModel.audits = ['seo']; + mockModel.isAuditEnabled = (type) => mockModel.audits.includes(type); + + instance.allBySiteId = stub().resolves([mockModel]); + + const result = await instance.allBySiteIdAndAuditType('site123', 'accessibility'); + + expect(result).to.be.an('array'); + expect(result).to.have.length(0); + }); + + it('passes pagination options to allBySiteId', async () => { + instance.allBySiteId = stub().resolves([]); + const options = { limit: 50, cursor: 'abc123' }; + + await instance.allBySiteIdAndAuditType('site123', 'accessibility', options); + + expect(instance.allBySiteId).to.have.been.calledOnceWith('site123', options); + }); + }); + + describe('removeForSiteId', () => { + it('throws an error if siteId is not provided', async () => { + await expect(instance.removeForSiteId()).to.be.rejectedWith('SiteId is required'); + }); + + it('removes all audit URLs for a given siteId', async () => { + const siteId = 'site12345'; + instance.allBySiteId = stub().resolves([model]); + + await instance.removeForSiteId(siteId); + + expect(instance.allBySiteId).to.have.been.calledOnceWith(siteId); + expect(mockElectroService.entities.auditUrl.delete).to.have.been.calledOnceWith([{ auditUrlId: 'au12345' }]); + }); + + it('does not call remove when there are no audit URLs', async () => { + const siteId = 'site12345'; + instance.allBySiteId = stub().resolves([]); + + await instance.removeForSiteId(siteId); + + expect(instance.allBySiteId).to.have.been.calledOnceWith(siteId); + expect(mockElectroService.entities.auditUrl.delete).to.not.have.been.called; + }); + }); + + describe('removeForSiteIdByCustomer', () => { + it('throws an error if siteId is not provided', async () => { + await expect(instance.removeForSiteIdByCustomer()).to.be.rejectedWith('SiteId is required and byCustomer must be a boolean'); + }); + + it('throws an error if byCustomer is not a boolean', async () => { + await expect(instance.removeForSiteIdByCustomer('site123')).to.be.rejectedWith('SiteId is required and byCustomer must be a boolean'); + }); + + it('removes all audit URLs for a given siteId and byCustomer flag', async () => { + const siteId = 'site12345'; + const byCustomer = true; + instance.allBySiteIdByCustomer = stub().resolves([model]); + + await instance.removeForSiteIdByCustomer(siteId, byCustomer); + + expect(instance.allBySiteIdByCustomer).to.have.been.calledOnceWith(siteId, byCustomer); + expect(mockElectroService.entities.auditUrl.delete).to.have.been.calledOnceWith([{ auditUrlId: 'au12345' }]); + }); + + it('does not call remove when there are no matching audit URLs', async () => { + const siteId = 'site12345'; + const byCustomer = false; + instance.allBySiteIdByCustomer = stub().resolves([]); + + await instance.removeForSiteIdByCustomer(siteId, byCustomer); + + expect(instance.allBySiteIdByCustomer).to.have.been.calledOnceWith(siteId, byCustomer); + expect(mockElectroService.entities.auditUrl.delete).to.not.have.been.called; + }); + }); + + describe('sortAuditUrls', () => { + it('returns empty array when input is empty', () => { + const result = AuditUrlCollection.sortAuditUrls([]); + expect(result).to.deep.equal([]); + }); + + it('returns null when input is null', () => { + const result = AuditUrlCollection.sortAuditUrls(null); + expect(result).to.be.null; + }); + + it('sorts by url alphabetically in ascending order', () => { + const url1 = { getUrl: () => 'https://a.com' }; + const url2 = { getUrl: () => 'https://c.com' }; + const url3 = { getUrl: () => 'https://b.com' }; + + const result = AuditUrlCollection.sortAuditUrls([url2, url1, url3], 'url', 'asc'); + + expect(result[0]).to.equal(url1); + expect(result[1]).to.equal(url3); + expect(result[2]).to.equal(url2); + }); + + it('sorts by url in descending order', () => { + const url1 = { getUrl: () => 'https://a.com' }; + const url2 = { getUrl: () => 'https://c.com' }; + const url3 = { getUrl: () => 'https://b.com' }; + + const result = AuditUrlCollection.sortAuditUrls([url1, url3, url2], 'url', 'desc'); + + expect(result[0]).to.equal(url2); + expect(result[1]).to.equal(url3); + expect(result[2]).to.equal(url1); + }); + + it('sorts by createdAt in ascending order', () => { + const url1 = { getCreatedAt: () => '2025-01-01T00:00:00Z', getUrl: () => 'url1' }; + const url2 = { getCreatedAt: () => '2025-01-03T00:00:00Z', getUrl: () => 'url2' }; + const url3 = { getCreatedAt: () => '2025-01-02T00:00:00Z', getUrl: () => 'url3' }; + + const result = AuditUrlCollection.sortAuditUrls([url2, url1, url3], 'createdAt', 'asc'); + + expect(result[0]).to.equal(url1); + expect(result[1]).to.equal(url3); + expect(result[2]).to.equal(url2); + }); + + it('sorts by updatedAt in ascending order', () => { + const url1 = { getUpdatedAt: () => '2025-01-01T00:00:00Z', getUrl: () => 'url1' }; + const url2 = { getUpdatedAt: () => '2025-01-03T00:00:00Z', getUrl: () => 'url2' }; + const url3 = { getUpdatedAt: () => '2025-01-02T00:00:00Z', getUrl: () => 'url3' }; + + const result = AuditUrlCollection.sortAuditUrls([url2, url1, url3], 'updatedAt', 'asc'); + + expect(result[0]).to.equal(url1); + expect(result[1]).to.equal(url3); + expect(result[2]).to.equal(url2); + }); + + it('handles null values by pushing them to the end', () => { + const url1 = { getCreatedAt: () => '2025-01-01T00:00:00Z', getUrl: () => 'url1' }; + const url2 = { getCreatedAt: () => null, getUrl: () => 'url2' }; + const url3 = { getCreatedAt: () => '2025-01-02T00:00:00Z', getUrl: () => 'url3' }; + + const result = AuditUrlCollection.sortAuditUrls([url2, url1, url3], 'createdAt', 'asc'); + + expect(result[0]).to.equal(url1); + expect(result[1]).to.equal(url3); + expect(result[2]).to.equal(url2); + }); + + it('handles objects without getter methods (uses optional chaining)', () => { + const url1 = { url: 'https://a.com' }; + const url2 = { url: 'https://c.com' }; + const url3 = { url: 'https://b.com' }; + + const result = AuditUrlCollection.sortAuditUrls([url2, url1, url3], 'url', 'asc'); + + expect(result[0]).to.equal(url1); + expect(result[1]).to.equal(url3); + expect(result[2]).to.equal(url2); + }); + + it('returns original order for unknown sortBy field', () => { + const url1 = { getUrl: () => 'url1' }; + const url2 = { getUrl: () => 'url2' }; + + const result = AuditUrlCollection.sortAuditUrls([url1, url2], 'unknown', 'asc'); + + expect(result[0]).to.equal(url1); + expect(result[1]).to.equal(url2); + }); + }); + + describe('allBySiteIdSorted', () => { + it('throws an error if siteId is not provided', async () => { + await expect(instance.allBySiteIdSorted()).to.be.rejectedWith('SiteId is required'); + }); + + it('returns sorted URLs when sortBy is provided', async () => { + const url1 = { getCreatedAt: () => '2025-01-01T00:00:00Z', getUrl: () => 'url1' }; + const url2 = { getCreatedAt: () => '2025-01-03T00:00:00Z', getUrl: () => 'url2' }; + const url3 = { getCreatedAt: () => '2025-01-02T00:00:00Z', getUrl: () => 'url3' }; + + instance.allBySiteId = stub().resolves({ items: [url2, url1, url3], cursor: 'cursor123' }); + + const result = await instance.allBySiteIdSorted('site-123', { sortBy: 'createdAt', sortOrder: 'asc' }); + + expect(result.items).to.be.an('array').with.lengthOf(3); + expect(result.items[0]).to.equal(url1); + expect(result.items[1]).to.equal(url3); + expect(result.items[2]).to.equal(url2); + expect(result.cursor).to.equal('cursor123'); + }); + + it('returns unsorted URLs when sortBy is not provided', async () => { + const url1 = { getUrl: () => 'url1' }; + const url2 = { getUrl: () => 'url2' }; + + instance.allBySiteId = stub().resolves({ items: [url2, url1] }); + + const result = await instance.allBySiteIdSorted('site-123', {}); + + expect(result.items).to.deep.equal([url2, url1]); + }); + + it('handles array result format', async () => { + const url1 = { getCreatedAt: () => '2025-01-01T00:00:00Z', getUrl: () => 'url1' }; + const url2 = { getCreatedAt: () => '2025-01-02T00:00:00Z', getUrl: () => 'url2' }; + + instance.allBySiteId = stub().resolves([url2, url1]); + + const result = await instance.allBySiteIdSorted('site-123', { sortBy: 'createdAt', sortOrder: 'asc' }); + + expect(result).to.be.an('array').with.lengthOf(2); + expect(result[0]).to.equal(url1); + expect(result[1]).to.equal(url2); + }); + + it('passes query options to allBySiteId', async () => { + instance.allBySiteId = stub().resolves({ items: [] }); + + await instance.allBySiteIdSorted('site-123', { limit: 10, cursor: 'abc', sortBy: 'createdAt' }); + + expect(instance.allBySiteId).to.have.been.calledOnceWith('site-123', { limit: 10, cursor: 'abc' }); + }); + }); + + describe('allBySiteIdByCustomerSorted', () => { + it('throws an error if siteId is not provided', async () => { + await expect(instance.allBySiteIdByCustomerSorted()).to.be.rejectedWith('SiteId is required and byCustomer must be a boolean'); + }); + + it('throws an error if byCustomer is not a boolean', async () => { + await expect(instance.allBySiteIdByCustomerSorted('site-123', 'not-a-boolean')).to.be.rejectedWith('SiteId is required and byCustomer must be a boolean'); + }); + + it('returns sorted URLs when sortBy is provided', async () => { + const url1 = { getCreatedAt: () => '2025-01-01T00:00:00Z', getUrl: () => 'url1' }; + const url2 = { getCreatedAt: () => '2025-01-03T00:00:00Z', getUrl: () => 'url2' }; + const url3 = { getCreatedAt: () => '2025-01-02T00:00:00Z', getUrl: () => 'url3' }; + + instance.allBySiteIdByCustomer = stub().resolves({ items: [url2, url1, url3], cursor: 'cursor123' }); + + const result = await instance.allBySiteIdByCustomerSorted('site-123', true, { sortBy: 'createdAt', sortOrder: 'asc' }); + + expect(result.items).to.be.an('array').with.lengthOf(3); + expect(result.items[0]).to.equal(url1); + expect(result.items[1]).to.equal(url3); + expect(result.items[2]).to.equal(url2); + expect(result.cursor).to.equal('cursor123'); + }); + + it('returns unsorted URLs when sortBy is not provided', async () => { + const url1 = { getUrl: () => 'url1' }; + const url2 = { getUrl: () => 'url2' }; + + instance.allBySiteIdByCustomer = stub().resolves({ items: [url2, url1] }); + + const result = await instance.allBySiteIdByCustomerSorted('site-123', false, {}); + + expect(result.items).to.deep.equal([url2, url1]); + }); + + it('handles array result format', async () => { + const url1 = { getCreatedAt: () => '2025-01-01T00:00:00Z', getUrl: () => 'url1' }; + const url2 = { getCreatedAt: () => '2025-01-02T00:00:00Z', getUrl: () => 'url2' }; + + instance.allBySiteIdByCustomer = stub().resolves([url2, url1]); + + const result = await instance.allBySiteIdByCustomerSorted('site-123', true, { sortBy: 'createdAt', sortOrder: 'asc' }); + + expect(result).to.be.an('array').with.lengthOf(2); + expect(result[0]).to.equal(url1); + expect(result[1]).to.equal(url2); + }); + + it('passes query options to allBySiteIdByCustomer', async () => { + instance.allBySiteIdByCustomer = stub().resolves({ items: [] }); + + await instance.allBySiteIdByCustomerSorted('site-123', true, { limit: 10, cursor: 'abc', sortBy: 'createdAt' }); + + expect(instance.allBySiteIdByCustomer).to.have.been.calledOnceWith('site-123', true, { limit: 10, cursor: 'abc' }); + }); + }); + + describe('allBySiteIdAndAuditType with sorting', () => { + it('applies sorting when sortBy is provided', async () => { + const mockModel1 = Object.create(AuditUrl.prototype); + mockModel1.audits = ['accessibility']; + mockModel1.isAuditEnabled = (type) => mockModel1.audits.includes(type); + mockModel1.getCreatedAt = () => '2025-01-02T00:00:00Z'; + + const mockModel2 = Object.create(AuditUrl.prototype); + mockModel2.audits = ['accessibility']; + mockModel2.isAuditEnabled = (type) => mockModel2.audits.includes(type); + mockModel2.getCreatedAt = () => '2025-01-01T00:00:00Z'; + + instance.allBySiteId = stub().resolves([mockModel1, mockModel2]); + + const result = await instance.allBySiteIdAndAuditType('site123', 'accessibility', { sortBy: 'createdAt', sortOrder: 'asc' }); + + expect(result).to.be.an('array').with.lengthOf(2); + expect(result[0]).to.equal(mockModel2); + expect(result[1]).to.equal(mockModel1); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/audit-url/audit-url.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/audit-url/audit-url.model.test.js new file mode 100644 index 000000000..22a02a2f4 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/audit-url/audit-url.model.test.js @@ -0,0 +1,177 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import AuditUrl from '../../../../src/models/audit-url/audit-url.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('AuditUrlModel', () => { + let instance; + let mockRecord; + + beforeEach(() => { + mockRecord = { + auditUrlId: 'au12345', + siteId: 'site12345', + url: 'https://example.com/page', + byCustomer: true, + audits: ['accessibility', 'broken-backlinks'], + createdAt: '2025-10-27T12:00:00.000Z', + createdBy: 'user@example.com', + updatedAt: '2025-10-27T12:00:00.000Z', + updatedBy: 'user@example.com', + }; + + ({ + model: instance, + } = createElectroMocks(AuditUrl, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the AuditUrl instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('isAuditEnabled', () => { + it('returns true when audit is enabled', () => { + expect(instance.isAuditEnabled('accessibility')).to.be.true; + expect(instance.isAuditEnabled('broken-backlinks')).to.be.true; + }); + + it('returns false when audit is not enabled', () => { + expect(instance.isAuditEnabled('lhs-mobile')).to.be.false; + expect(instance.isAuditEnabled('seo')).to.be.false; + }); + + it('handles empty audits array', () => { + instance.record.audits = []; + expect(instance.isAuditEnabled('accessibility')).to.be.false; + }); + + it('handles undefined audits', () => { + instance.record.audits = undefined; + expect(instance.isAuditEnabled('accessibility')).to.be.false; + }); + + it('works with direct property access when getAudits is not available', () => { + const plainObj = Object.create(AuditUrl.prototype); + plainObj.audits = ['accessibility']; + expect(plainObj.isAuditEnabled('accessibility')).to.be.true; + }); + }); + + describe('enableAudit', () => { + it('adds audit to the list when not present', () => { + instance.enableAudit('lhs-mobile'); + expect(instance.getAudits()).to.include('lhs-mobile'); + }); + + it('does not add duplicate audits', () => { + const originalLength = instance.getAudits().length; + instance.enableAudit('accessibility'); // Already exists + expect(instance.getAudits().length).to.equal(originalLength); + }); + + it('returns the instance for method chaining', () => { + const result = instance.enableAudit('seo'); + expect(result).to.equal(instance); + }); + + it('works when starting with empty audits array', () => { + instance.record.audits = []; + instance.enableAudit('accessibility'); + expect(instance.getAudits()).to.deep.equal(['accessibility']); + }); + + it('works with direct property access', () => { + const plainObj = Object.create(AuditUrl.prototype); + plainObj.audits = []; + plainObj.enableAudit('accessibility'); + expect(plainObj.audits).to.deep.equal(['accessibility']); + }); + }); + + describe('disableAudit', () => { + it('removes audit from the list when present', () => { + instance.disableAudit('accessibility'); + expect(instance.getAudits()).to.not.include('accessibility'); + }); + + it('does nothing if audit is not in the list', () => { + const originalLength = instance.getAudits().length; + instance.disableAudit('seo'); // Not in list + expect(instance.getAudits().length).to.equal(originalLength); + }); + + it('returns the instance for method chaining', () => { + const result = instance.disableAudit('accessibility'); + expect(result).to.equal(instance); + }); + + it('handles removing all audits', () => { + instance.disableAudit('accessibility'); + instance.disableAudit('broken-backlinks'); + expect(instance.getAudits()).to.deep.equal([]); + }); + + it('works with direct property access', () => { + const plainObj = Object.create(AuditUrl.prototype); + plainObj.audits = ['accessibility', 'seo']; + plainObj.disableAudit('accessibility'); + expect(plainObj.audits).to.deep.equal(['seo']); + }); + }); + + describe('isCustomerUrl', () => { + it('returns true for customer-added URL', () => { + instance.record.byCustomer = true; + expect(instance.isCustomerUrl()).to.be.true; + }); + + it('returns false for system-added URL', () => { + instance.record.byCustomer = false; + expect(instance.isCustomerUrl()).to.be.false; + }); + + it('works with direct property access', () => { + const plainObj = Object.create(AuditUrl.prototype); + plainObj.byCustomer = true; + expect(plainObj.isCustomerUrl()).to.be.true; + + plainObj.byCustomer = false; + expect(plainObj.isCustomerUrl()).to.be.false; + }); + }); + + describe('method chaining', () => { + it('allows chaining enableAudit and disableAudit', () => { + instance + .enableAudit('seo') + .enableAudit('lhs-mobile') + .disableAudit('accessibility'); + + expect(instance.isAuditEnabled('seo')).to.be.true; + expect(instance.isAuditEnabled('lhs-mobile')).to.be.true; + expect(instance.isAuditEnabled('accessibility')).to.be.false; + }); + }); +});