diff --git a/package-lock.json b/package-lock.json index 3b5ab4f7..491df4e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "node_modules/@adobe/eslint-config-helix": { @@ -18502,7 +18502,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-ahrefs-client/node_modules/@adobe/spacecat-shared-utils": { @@ -19277,7 +19277,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-content-client/node_modules/@adobe/fetch": { @@ -20793,7 +20793,7 @@ }, "packages/spacecat-shared-data-access": { "name": "@adobe/spacecat-shared-data-access", - "version": "1.59.2", + "version": "1.60.1", "license": "Apache-2.0", "dependencies": { "@adobe/spacecat-shared-dynamo": "1.4.0", @@ -20816,7 +20816,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-data-access/node_modules/@adobe/fetch": { @@ -21121,7 +21121,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-dynamo/node_modules/@adobe/fetch": { @@ -21887,7 +21887,7 @@ "devDependencies": {}, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-google-client": { @@ -21914,7 +21914,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-google-client/node_modules/@adobe/spacecat-shared-data-access": { @@ -22951,7 +22951,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-gpt-client/node_modules/@adobe/spacecat-shared-ims-client": { @@ -23754,7 +23754,7 @@ }, "packages/spacecat-shared-http-utils": { "name": "@adobe/spacecat-shared-http-utils", - "version": "1.7.3", + "version": "1.8.0", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.1.11", @@ -23770,7 +23770,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-http-utils/node_modules/@adobe/spacecat-shared-data-access": { @@ -25298,7 +25298,7 @@ }, "packages/spacecat-shared-ims-client": { "name": "@adobe/spacecat-shared-ims-client", - "version": "1.3.27", + "version": "1.4.0", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.1.11", @@ -25315,7 +25315,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-ims-client/node_modules/@adobe/spacecat-shared-utils": { @@ -26091,7 +26091,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-rum-api-client/node_modules/@adobe/spacecat-shared-utils": { @@ -26846,7 +26846,7 @@ }, "packages/spacecat-shared-slack-client": { "name": "@adobe/spacecat-shared-slack-client", - "version": "1.3.28", + "version": "1.4.0", "license": "Apache-2.0", "dependencies": { "@adobe/helix-universal": "5.0.8", @@ -26864,7 +26864,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-slack-client/node_modules/@adobe/fetch": { @@ -27641,7 +27641,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-utils/node_modules/@adobe/spacecat-shared-data-access": { diff --git a/package.json b/package.json index f0b77e2d..910c76ed 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "scripts": { "docs:api": "jsdoc2md -c .jsdoc.json --files packages/*/src/*.js > docs/API.md", diff --git a/packages/spacecat-shared-ahrefs-client/package.json b/packages/spacecat-shared-ahrefs-client/package.json index 06d35b19..49543004 100644 --- a/packages/spacecat-shared-ahrefs-client/package.json +++ b/packages/spacecat-shared-ahrefs-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-content-client/package.json b/packages/spacecat-shared-content-client/package.json index 15aaf37e..cdb2ee14 100644 --- a/packages/spacecat-shared-content-client/package.json +++ b/packages/spacecat-shared-content-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-data-access/CHANGELOG.md b/packages/spacecat-shared-data-access/CHANGELOG.md index 7f3c2fd4..a6522588 100755 --- a/packages/spacecat-shared-data-access/CHANGELOG.md +++ b/packages/spacecat-shared-data-access/CHANGELOG.md @@ -1,3 +1,17 @@ +# [@adobe/spacecat-shared-data-access-v1.60.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.60.0...@adobe/spacecat-shared-data-access-v1.60.1) (2024-12-18) + + +### Bug Fixes + +* removeElectroProperties ([#498](https://github.com/adobe/spacecat-shared/issues/498)) ([a21b188](https://github.com/adobe/spacecat-shared/commit/a21b1887d6872092679f9c5d02452e79711955e7)) + +# [@adobe/spacecat-shared-data-access-v1.60.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.59.2...@adobe/spacecat-shared-data-access-v1.60.0) (2024-12-18) + + +### Features + +* migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) + # [@adobe/spacecat-shared-data-access-v1.59.2](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.59.1...@adobe/spacecat-shared-data-access-v1.59.2) (2024-12-14) diff --git a/packages/spacecat-shared-data-access/docs/schema.json b/packages/spacecat-shared-data-access/docs/schema.json index 03833dc0..4a6a1e7e 100644 --- a/packages/spacecat-shared-data-access/docs/schema.json +++ b/packages/spacecat-shared-data-access/docs/schema.json @@ -40,68 +40,156 @@ ], "GlobalSecondaryIndexes": [ { - "IndexName": "spacecat-data-opportunity-by-site", + "IndexName": "spacecat-data-ApiKey-byHashedApiKey", "KeyAttributes": { - "PartitionKey": { - "AttributeName": "gsi1pk", - "AttributeType": "S" - }, - "SortKey": { - "AttributeName": "gsi1sk", - "AttributeType": "S" - } + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } }, - "Projection": { - "ProjectionType": "ALL" - } + "Projection": { "ProjectionType": "ALL" } }, { - "IndexName": "spacecat-data-opportunity-by-site-and-status", + "IndexName": "spacecat-data-ApiKey-byImsOrgIdAndImsUserId", "KeyAttributes": { - "PartitionKey": { - "AttributeName": "gsi2pk", - "AttributeType": "S" - }, - "SortKey": { - "AttributeName": "gsi2sk", - "AttributeType": "S" - } + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } }, - "Projection": { - "ProjectionType": "ALL" - } + "Projection": { "ProjectionType": "ALL" } }, { - "IndexName": "spacecat-data-suggestion-by-opportunity", + "IndexName": "spacecat-data-Opportunity-byAuditId", "KeyAttributes": { - "PartitionKey": { - "AttributeName": "gsi1pk", - "AttributeType": "S" - }, - "SortKey": { - "AttributeName": "gsi1sk", - "AttributeType": "S" - } + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } }, - "Projection": { - "ProjectionType": "ALL" - } + "Projection": { "ProjectionType": "ALL" } }, { - "IndexName": "spacecat-data-suggestion-by-opportunity-and-status", + "IndexName": "spacecat-data-Opportunity-bySiteId", "KeyAttributes": { - "PartitionKey": { - "AttributeName": "gsi2pk", - "AttributeType": "S" - }, - "SortKey": { - "AttributeName": "gsi2sk", - "AttributeType": "S" - } + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } }, - "Projection": { - "ProjectionType": "ALL" - } + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Suggestion-byOpportunityId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Site-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Site-byOrganizationId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Site-byDeliveryType", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi3pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi3sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Organization-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Audit-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Experiment-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-KeyEvent-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-SiteCandidate-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-SiteCandidate-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-SiteTopPage-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Configuration-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "version", "AttributeType": "N" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-ImportJob-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-ImportJob-byStatus", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-ImportUrl-byImportJobId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } } ] }, diff --git a/packages/spacecat-shared-data-access/package.json b/packages/spacecat-shared-data-access/package.json index 8f323730..72c51e87 100644 --- a/packages/spacecat-shared-data-access/package.json +++ b/packages/spacecat-shared-data-access/package.json @@ -1,16 +1,16 @@ { "name": "@adobe/spacecat-shared-data-access", - "version": "1.59.2", + "version": "1.60.1", "description": "Shared modules of the Spacecat Services - Data Access", "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", "scripts": { - "test:it": "mocha --spec \"test/it/**/*.test.js\"", + "test:it": "mocha --require ./test/it/fixtures.js --spec \"test/it/**/*.test.js\"", "test": "c8 mocha --spec \"test/unit/**/*.test.js\"", "lint": "eslint .", "clean": "rm -rf package-lock.json node_modules" diff --git a/packages/spacecat-shared-data-access/src/models/site/config.js b/packages/spacecat-shared-data-access/src/models/site/config.js index 08498295..89a30add 100644 --- a/packages/spacecat-shared-data-access/src/models/site/config.js +++ b/packages/spacecat-shared-data-access/src/models/site/config.js @@ -45,7 +45,7 @@ export const DEFAULT_CONFIG = { }; // Function to validate incoming configuration -function validateConfiguration(config) { +export function validateConfiguration(config) { const { error, value } = configSchema.validate(config); if (error) { diff --git a/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js index 2936f756..0de981b1 100644 --- a/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js @@ -21,7 +21,7 @@ import { createAudit } from '../../models/audit.js'; * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which audits are being retrieved. * @param {string} [auditType] - Optional. The type of audits to retrieve. * @param {boolean} [ascending] - Optional. Determines if the audits should be sorted @@ -61,7 +61,7 @@ export const getAuditsForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which to retrieve the audit. * @param {string} auditType - The type of audit to retrieve. * @param auditedAt - The ISO 8601 timestamp of the audit. @@ -88,7 +88,7 @@ export const getAuditForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} auditType - The type of audits to retrieve. * @param {boolean} ascending - Determines if the audits should be sorted ascending * or descending by scores. @@ -121,7 +121,7 @@ export const getLatestAudits = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which audits are being retrieved. * @returns {Promise[]>} A promise that resolves to an array of latest audits * for the specified site. @@ -148,7 +148,7 @@ export const getLatestAuditsForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which the latest audit is being retrieved. * @param {string} auditType - The type of audit to retrieve the latest instance of. * @returns {Promise} A promise that resolves to the latest audit of the @@ -174,7 +174,7 @@ export const getLatestAuditForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {object} auditData - The audit data. * @returns {Promise>} */ @@ -261,7 +261,7 @@ async function removeAudits( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which audits are being removed. * @returns {Promise} */ diff --git a/packages/spacecat-shared-data-access/src/service/experiments/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/experiments/accessPatterns.js index b44d224f..cbf89620 100644 --- a/packages/spacecat-shared-data-access/src/service/experiments/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/experiments/accessPatterns.js @@ -44,7 +44,7 @@ export const getExperiment = async (dynamoClient, config, siteId, experimentId, * Retrieves all experiments for a given siteId. * @param {*} dynamoClient - The DynamoDB client. * @param {*} config - The data access config. - * @param {*} log - the logger object + * @param {*} log - the log object * @param {*} siteId - siteId of the experiment. * @param {*} experimentId - experiment id. * @returns {Promise>} A promise that resolves to @@ -77,7 +77,7 @@ export const getExperiments = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {object} log - the logger object + * @param {object} log - the log object * @param {object} experimentData - The experiment data. * @returns {Promise>} A promise that resolves to newly created/updated * experiment diff --git a/packages/spacecat-shared-data-access/src/service/import-job/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/import-job/accessPatterns.js index 9185c3f2..37df5af5 100644 --- a/packages/spacecat-shared-data-access/src/service/import-job/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/import-job/accessPatterns.js @@ -118,7 +118,7 @@ export const updateImportJob = async (dynamoClient, config, log, importJob) => { * Removes an Import Job and all associated URLs. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {ImportJob} importJob - The import job to remove. * @return {Promise} A promise that resolves when the import job has been removed. */ diff --git a/packages/spacecat-shared-data-access/src/service/import-url/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/import-url/accessPatterns.js index c3d866b0..844023df 100644 --- a/packages/spacecat-shared-data-access/src/service/import-url/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/import-url/accessPatterns.js @@ -101,7 +101,7 @@ export const getImportUrlsByJobIdAndStatus = async (dynamoClient, config, log, j * Get Import Urls by Job ID, if no urls exist an empty array is returned. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} jobId - The ID of the import job. * @returns {Promise} */ @@ -138,7 +138,7 @@ async function removeUrls(dynamoClient, config, urls) { * Remove all URLs associated with an import job. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} jobId - The ID of the import job. * @return {Promise} A promise that resolves when all URLs have been removed. */ diff --git a/packages/spacecat-shared-data-access/src/service/index.js b/packages/spacecat-shared-data-access/src/service/index.js old mode 100644 new mode 100755 index d3f3cb48..88a293e9 --- a/packages/spacecat-shared-data-access/src/service/index.js +++ b/packages/spacecat-shared-data-access/src/service/index.js @@ -13,14 +13,11 @@ import { createClient } from '@adobe/spacecat-shared-dynamo'; import { DynamoDB } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'; + import AWSXray from 'aws-xray-sdk'; import { Service } from 'electrodb'; -import ModelFactory from '../v2/models/model.factory.js'; -import OpportunityCollection from '../v2/models/opportunity.collection.js'; -import SuggestionCollection from '../v2/models/suggestion.collection.js'; -import OpportunitySchema from '../v2/schema/opportunity.schema.js'; -import SuggestionSchema from '../v2/schema/suggestion.schema.js'; +import EntityRegistry from '../v2/models/base/entity.registry.js'; import { auditFunctions } from './audits/index.js'; import { keyEventFunctions } from './key-events/index.js'; @@ -51,11 +48,9 @@ const createElectroService = (client, config, log) => { log.debug(JSON.stringify(event, null, 4)); }; /* c8 ignore end */ + return new Service( - { - opportunity: OpportunitySchema, - suggestion: SuggestionSchema, - }, + EntityRegistry.getEntities(), { client, table, @@ -74,11 +69,11 @@ const createElectroService = (client, config, log) => { * tableNameImportJobs: string, pkAllImportJobs: string, indexNameAllImportJobs: string, * tableNameSiteTopPages: string, indexNameAllOrganizations: string, * indexNameAllOrganizationsByImsOrgId: string, pkAllOrganizations: string}} config configuration - * @param {Logger} log logger + * @param {Logger} log log * @returns {object} data access object */ -export const createDataAccess = (config, log = console) => { - const dynamoClient = createClient(log); +export const createDataAccess = (config, log = console, client = undefined) => { + const dynamoClient = createClient(log, client); const auditFuncs = auditFunctions(dynamoClient, config, log); const keyEventFuncs = keyEventFunctions(dynamoClient, config, log); @@ -95,10 +90,8 @@ export const createDataAccess = (config, log = console) => { // electro-based data access objects const rawClient = createRawClient(); const electroService = createElectroService(rawClient, config, log); - const modelFactory = new ModelFactory(electroService, log); - - const Opportunity = modelFactory.getCollection(OpportunityCollection.name); - const Suggestion = modelFactory.getCollection(SuggestionCollection.name); + const entityRegistry = new EntityRegistry(electroService, log); + const collections = entityRegistry.getCollections(); return { ...auditFuncs, @@ -113,7 +106,6 @@ export const createDataAccess = (config, log = console) => { ...experimentFuncs, ...apiKeyFuncs, // electro-based data access objects - Opportunity, - Suggestion, + ...collections, }; }; diff --git a/packages/spacecat-shared-data-access/src/service/key-events/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/key-events/accessPatterns.js index 31a2833c..c8680a4a 100644 --- a/packages/spacecat-shared-data-access/src/service/key-events/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/key-events/accessPatterns.js @@ -18,7 +18,7 @@ import { KeyEventDto } from '../../dto/key-event.js'; * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {object} log - the logger object + * @param {object} log - the log object * @param {object} keyEventData - The key event data. * @returns {Promise>} newly created key event */ @@ -43,7 +43,7 @@ export const addKeyEvent = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which key events are being retrieved. * @param {boolean} ascending - Determines if the key events should be sorted ascending * or descending by createdAt. @@ -73,7 +73,7 @@ export const getKeyEventsForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} keyEventId - The ID of the key event to remove. * @returns {Promise} */ diff --git a/packages/spacecat-shared-data-access/src/service/organizations/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/organizations/accessPatterns.js index 3def58d2..44210c65 100644 --- a/packages/spacecat-shared-data-access/src/service/organizations/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/organizations/accessPatterns.js @@ -81,7 +81,7 @@ export const getOrganizationByImsOrgID = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {object} organizationData - The organization data. * @returns {Promise>} */ @@ -106,7 +106,7 @@ export const addOrganization = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {Organization} organization - The organization. * @returns {Promise>} - The updated organization. */ @@ -139,7 +139,7 @@ export const updateOrganization = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} organizationId - The ID of the organization to remove. * @returns {Promise} */ diff --git a/packages/spacecat-shared-data-access/src/service/site-candidates/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/site-candidates/accessPatterns.js index 0cc0c37e..776c3547 100644 --- a/packages/spacecat-shared-data-access/src/service/site-candidates/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/site-candidates/accessPatterns.js @@ -58,7 +58,7 @@ export const getSiteCandidateByBaseURL = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {object} log - the logger object + * @param {object} log - the log object * @param {object} siteCandidateData - The site candidate data. * @returns {Promise>} newly created site candidate if hadn't created before */ diff --git a/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js index f4e9e1a0..148d5ec9 100644 --- a/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js @@ -89,7 +89,7 @@ export const getSitesToAudit = async (dynamoClient, config) => { * the list. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} auditType - The type of audits to retrieve for the sites. * @param {boolean} [sortAuditsAscending=true] - Determines if the audits should be sorted in * ascending order. @@ -138,7 +138,7 @@ export const getSitesWithLatestAudit = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} baseURL - The base URL of the site to retrieve. * @returns {Promise|null>} A promise that resolves to the site object if found, * otherwise null. @@ -168,7 +168,7 @@ export const getSiteByBaseURL = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} baseUrl - The base URL of the site to retrieve. * @param {string} auditType - The type of audits to retrieve for the site. * @param {boolean} [latestOnly=false] - Determines if only the latest audit should be retrieved. @@ -215,7 +215,7 @@ export const getSiteByBaseURLWithAuditInfo = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} baseUrl - The base URL of the site to retrieve. * @param {string} auditType - The type of audits to retrieve for the site. * @returns {Promise|null>} A promise that resolves to the site object @@ -234,7 +234,7 @@ export const getSiteByBaseURLWithAudits = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} baseUrl - The base URL of the site to retrieve. * @param {string} auditType - The type of the latest audit to retrieve for the site. * @returns {Promise|null>} A promise that resolves to the site object @@ -272,7 +272,7 @@ export const getSitesByOrganizationID = async ( * The sortAuditsAscending parameter can be used to change the sort order. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} auditType - The type of audits to retrieve for the sites. * @param {string} organizationId - The organizationId to retrieve the sites. * @param {boolean} [sortAuditsAscending=true] - Determines if the audits should be sorted in @@ -321,7 +321,7 @@ export const getSitesByOrganizationIDWithLatestAudits = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site to retrieve. * @returns {Promise|null>} A promise that resolves to the site object if found, * otherwise null. @@ -341,7 +341,7 @@ export const getSiteByID = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {object} siteData - The site data. * @returns {Promise>} */ @@ -373,7 +373,7 @@ export const addSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {Site} site - The site. * @returns {Promise>} - The updated site. */ @@ -399,7 +399,7 @@ export const updateSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site to remove. * @returns {Promise} */ @@ -446,7 +446,7 @@ async function removeSites( * Removes all sites for an organization. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} organizationId - The ID of the organization to remove the sites for. * @return {Promise} A promise that resolves when all sites for the organization have been * removed. diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.collection.js b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.collection.js new file mode 100755 index 00000000..292518e4 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.collection.js @@ -0,0 +1,26 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * ApiKeyCollection - A collection class responsible for managing ApiKey entities. + * Extends the BaseCollection to provide specific methods for interacting with ApiKey records. + * + * @class ApiKeyCollection + * @extends BaseCollection + */ +class ApiKeyCollection extends BaseCollection { + // add custom methods here +} + +export default ApiKeyCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.model.js b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.model.js new file mode 100644 index 00000000..c3030fcd --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.model.js @@ -0,0 +1,59 @@ +/* + * 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 { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import BaseModel from '../base/base.model.js'; + +/** + * ApiKey - A class representing an ApiKey entity. + * Provides methods to access and manipulate ApiKey-specific data. + * + * @class ApiKey + * @extends BaseModel + */ +class ApiKey extends BaseModel { + static SCOPE_NAMES = [ + 'sites.read_all', + 'sites.write_all', + 'organizations.read_all', + 'organizations.write_all', + 'audits.read_all', + 'audits.write_all', + 'imports.read', + 'imports.write', + 'imports.delete', + 'imports.read_all', + 'imports.all_domains', + 'imports.assistant', + ]; + + isValid() { + const now = new Date(); + + if (isIsoDate(this.getDeletedAt()) && new Date(this.getDeletedAt()) < now) { + return false; + } + + if (isIsoDate(this.getRevokedAt()) && new Date(this.getRevokedAt()) < now) { + return false; + } + + if (isIsoDate(this.getExpiresAt()) && new Date(this.getExpiresAt()) < now) { + return false; + } + + return true; + } +} + +export default ApiKey; diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.schema.js b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.schema.js new file mode 100644 index 00000000..358557b1 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.schema.js @@ -0,0 +1,82 @@ +/* + * 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 { isIsoDate, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import ApiKey from './api-key.model.js'; +import ApiKeyCollection from './api-key.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/ + */ + +const schema = new SchemaBuilder(ApiKey, ApiKeyCollection) + .addAttribute('hashedApiKey', { + type: 'string', + required: true, + }) + .addAttribute('imsUserId', { + type: 'string', + }) + .addAttribute('imsOrgId', { + type: 'string', + }) + .addAttribute('name', { + type: 'string', + required: true, + }) + .addAttribute('deletedAt', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('expiresAt', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('revokedAt', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('scopes', { + type: 'list', + required: true, + items: { + type: 'map', + properties: { + domains: { + type: 'list', + items: { + type: 'string', + validate: (value) => isValidUrl(value), + }, + }, + name: { type: ApiKey.SCOPE_NAMES }, + }, + }, + }) + .addIndex( + 'byHashedApiKey', + { composite: ['hashedApiKey'] }, + { composite: ['updatedAt'] }, + ) + .addIndex( + 'byImsOrgIdAndImsUserId', + { composite: ['imsOrgId', 'imsUserId'] }, + { composite: ['updatedAt'] }, + ); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/api-key/index.d.ts new file mode 100644 index 00000000..f3352768 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/index.d.ts @@ -0,0 +1,37 @@ +/* + * 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 } from '../base'; + +export interface ApiKey extends BaseModel { + getDeletedAt(): string | undefined; + getExpiresAt(): string | undefined; + getHashedApiKey(): string; + getImsOrgId(): string | undefined; + getImsUserId(): string | undefined; + getName(): string; + getRevokedAt(): string | undefined; + getScopes(): string[]; + setDeletedAt(deletedAt: string): void; + setExpiresAt(expiresAt: string): void; + setHashedApiKey(hashedApiKey: string): void; + setImsOrgId(imsOrgId: string): void; + setImsUserId(imsUserId: string): void; + setName(name: string): void; + setRevokedAt(revokedAt: string): void; + setScopes(scopes: object[]): void; +} + +export interface ApiKeyCollection extends BaseCollection { + allByImsOrgIdAndImsUserId: (imsUserId: string, imsOrgId: string) => Promise; + findByHashedApiKey: (hashedApiKey: string) => Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/index.js b/packages/spacecat-shared-data-access/src/v2/models/api-key/index.js new file mode 100644 index 00000000..52de1cbb --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/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 ApiKey from './api-key.model.js'; +import ApiKeyCollection from './api-key.collection.js'; + +export { + ApiKey, + ApiKeyCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/audit.collection.js b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.collection.js new file mode 100755 index 00000000..b08fc02b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.collection.js @@ -0,0 +1,26 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * AuditCollection - A collection class responsible for managing Audit entities. + * Extends the BaseCollection to provide specific methods for interacting with Audit records. + * + * @class AuditCollection + * @extends BaseCollection + */ +class AuditCollection extends BaseCollection { + // add custom methods here +} + +export default AuditCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/audit.model.js b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.model.js new file mode 100644 index 00000000..34198334 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.model.js @@ -0,0 +1,89 @@ +/* + * 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 { isObject } from '@adobe/spacecat-shared-utils'; + +import { ValidationError } from '../../errors/index.js'; +import BaseModel from '../base/base.model.js'; + +const AUDIT_TYPES = { + 404: '404', + BROKEN_BACKLINKS: 'broken-backlinks', + EXPERIMENTATION: 'experimentation', + ORGANIC_KEYWORDS: 'organic-keywords', + ORGANIC_TRAFFIC: 'organic-traffic', + CWV: 'cwv', + LHS_DESKTOP: 'lhs-desktop', + LHS_MOBILE: 'lhs-mobile', + EXPERIMENTATION_ESS_MONTHLY: 'experimentation-ess-monthly', + EXPERIMENTATION_ESS_DAILY: 'experimentation-ess-daily', +}; + +const AUDIT_TYPE_PROPERTIES = { + [AUDIT_TYPES.LHS_DESKTOP]: ['performance', 'seo', 'accessibility', 'best-practices'], + [AUDIT_TYPES.LHS_MOBILE]: ['performance', 'seo', 'accessibility', 'best-practices'], +}; + +export const AUDIT_CONFIG = { + TYPES: AUDIT_TYPES, + PROPERTIES: AUDIT_TYPE_PROPERTIES, +}; + +/** + * Validates if the auditResult contains the required properties for the given audit type. + * @param {object} auditResult - The audit result to validate. + * @param {string} auditType - The type of the audit. + * @returns {boolean} - True if valid, false otherwise. + */ +export const validateAuditResult = (auditResult, auditType) => { + if (!isObject(auditResult) && !Array.isArray(auditResult)) { + throw new ValidationError('Audit result must be an object or array'); + } + + if (isObject(auditResult.runtimeError)) { + return true; + } + + if ((auditType === AUDIT_CONFIG.TYPES.LHS_MOBILE || auditType === AUDIT_CONFIG.TYPES.LHS_DESKTOP) + && !isObject(auditResult.scores)) { + throw new ValidationError(`Missing scores property for audit type '${auditType}'`); + } + + const expectedProperties = AUDIT_CONFIG.PROPERTIES[auditType]; + + if (expectedProperties) { + for (const prop of expectedProperties) { + if (!(prop in auditResult.scores)) { + throw new ValidationError(`Missing expected property '${prop}' for audit type '${auditType}'`); + } + } + } + + return true; +}; + +/** + * Audit - A class representing an Audit entity. + * Provides methods to access and manipulate Audit-specific data. + * + * @class Audit + * @extends BaseModel + */ +class Audit extends BaseModel { + // add your custom methods or overrides here + + getScores() { + return this.getAuditResult()?.scores; + } +} + +export default Audit; diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/audit.schema.js b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.schema.js new file mode 100644 index 00000000..1ab28252 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.schema.js @@ -0,0 +1,66 @@ +/* + * 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 { isIsoDate, isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Audit, { validateAuditResult } from './audit.model.js'; +import AuditCollection from './audit.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/ + */ + +const schema = new SchemaBuilder(Audit, AuditCollection) + .addReference('belongs_to', 'Site', ['auditType', 'auditedAt']) + .addReference('has_many', 'Opportunities') + .addAttribute('auditResult', { + type: 'any', + required: true, + validate: (value) => isNonEmptyObject(value), + set: (value, attributes) => { + // as the electroDb validate function does not provide access to the model instance + // we need to call the validate function from the model on setting the value + validateAuditResult(value, attributes.auditType); + return value; + }, + }) + .addAttribute('auditType', { + type: 'string', + required: true, + }) + .addAttribute('fullAuditRef', { + type: 'string', + required: true, + }) + .addAttribute('isLive', { + type: 'boolean', + required: true, + default: false, + }) + .addAttribute('isError', { + type: 'boolean', + required: true, + default: false, + }) + .addAttribute('auditedAt', { + type: 'string', + required: true, + default: () => new Date().toISOString(), + validate: (value) => isIsoDate(value), + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/audit/index.d.ts new file mode 100644 index 00000000..0e8995fd --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/index.d.ts @@ -0,0 +1,40 @@ +/* + * 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, Opportunity, Site, +} from '../index'; + +export interface Audit extends BaseModel { + getAuditResult(): object; + getAuditType(): string; + getAuditedAt(): number; + getFullAuditRef(): string; + getIsError(): boolean; + getIsLive(): boolean; + getOpportunities(): Promise; + getSite(): Promise; + getSiteId(): string; + setAuditResult(auditResult: object): Audit; + setAuditType(auditType: string): Audit; + setAuditedAt(auditedAt: number): Audit; + setFullAuditRef(fullAuditRef: string): Audit; + setIsError(isError: boolean): Audit; + setIsLive(isLive: boolean): Audit; + setSiteId(siteId: string): Audit; + toggleLive(): Audit; +} + +export interface AuditCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteAndType(siteId: string, auditType: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/index.js b/packages/spacecat-shared-data-access/src/v2/models/audit/index.js new file mode 100644 index 00000000..d7125d08 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/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 Audit from './audit.model.js'; +import AuditCollection from './audit.collection.js'; + +export { + Audit, + AuditCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base.collection.js b/packages/spacecat-shared-data-access/src/v2/models/base.collection.js deleted file mode 100755 index 1d8d8f63..00000000 --- a/packages/spacecat-shared-data-access/src/v2/models/base.collection.js +++ /dev/null @@ -1,275 +0,0 @@ -/* - * 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 { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; - -import { ElectroValidationError } from 'electrodb'; - -import ValidationError from '../errors/validation.error.js'; -import { guardId } from '../util/guards.js'; -import { keyNamesToIndexName } from '../util/reference.js'; - -/** - * BaseCollection - A base class for managing collections of entities in the application. - * This class uses ElectroDB to interact with entities and provides common functionality - * for data operations. - * - * @class BaseCollection - */ -class BaseCollection { - /** - * Constructs an instance of BaseCollection. - * @constructor - * @param {Object} electroService - The ElectroDB service used for managing entities. - * @param {Object} modelFactory - A factory for creating model instances. - * @param {BaseModel} clazz - The model class that represents the entity. - * @param {Object} log - A logger for capturing logging information. - */ - constructor(electroService, modelFactory, clazz, log) { - this.electroService = electroService; - this.modelFactory = modelFactory; - this.clazz = clazz; - this.entityName = this.clazz.name.toLowerCase(); - this.entity = electroService.entities[this.entityName]; - this.idName = `${this.entityName}Id`; - this.log = log; - } - - /** - * Creates an instance of a model from a record. - * @private - * @param {Object} record - The record containing data to create the model instance. - * @returns {BaseModel|null} - Returns an instance of the model class if the data is valid, - * otherwise null. - */ - #createInstance(record) { - if (!isNonEmptyObject(record?.data)) { - this.log.warn(`Failed to create instance of [${this.entityName}]: record is empty`); - return null; - } - // eslint-disable-next-line new-cap - return new this.clazz( - this.electroService, - this.modelFactory, - record.data, - this.log, - ); - } - - /** - * Creates instances of models from a set of records. - * @private - * @param {Object} records - The records containing data to create the model instances. - * @returns {Array} - An array of instances of the model class. - */ - #createInstances(records) { - if (!Array.isArray(records?.data)) { - this.log.warn(`Failed to create instances of [${this.entityName}]: records are empty`); - return []; - } - return records.data.map((record) => this.#createInstance({ data: record })); - } - - /** - * Retrieves the enum values for a field in the entity schema. Useful for validating - * enum values prior to creating or updating an entity. - * @param {string} fieldName - The name of the field to retrieve enum values for. - * @return {string[]} - An array of enum values for the field. - * @protected - */ - _getEnumValues(fieldName) { - return this.entity.model.schema.attributes[fieldName]?.enumArray; - } - - /** - * Finds an entity by its ID. - * @async - * @param {string} id - The unique identifier of the entity to be found. - * @returns {Promise} - A promise that resolves to an instance of - * the model if found, otherwise null. - * @throws {Error} - Throws an error if the ID is not provided. - */ - async findById(id) { - guardId(this.idName, id, this.entityName); - - const record = await this.entity.get({ [this.idName]: id }).go(); - - return this.#createInstance(record); - } - - /** - * Finds entities by a set of index keys. Index keys are used to query entities by - * a specific index defined in the entity schema. The index keys must match the - * fields defined in the index. - * @param {Object} keys - The index keys to use for the query. - * @return {Promise>} - A promise that resolves to an array of model instances. - * @throws {Error} - Throws an error if the index keys are not provided or if the index - * is not found. - * @async - */ - async findByIndexKeys(keys) { - if (!isNonEmptyObject(keys)) { - const message = `Failed to find by index keys [${this.entityName}]: keys are required`; - this.log.error(message); - throw new Error(message); - } - - const indexName = keyNamesToIndexName(Object.keys(keys)); - const index = this.entity.query[indexName]; - - if (!index) { - const message = `Failed to find by index keys [${this.entityName}]: index [${indexName}] not found`; - this.log.error(message); - throw new Error(message); - } - - const records = await index(keys).go(); - - return this.#createInstances(records); - } - - /** - * Creates a new entity in the collection and directly persists it to the database. - * There is no need to call the save method (which is for updates only) after creating - * the entity. - * @async - * @param {Object} item - The data for the entity to be created. - * @returns {Promise} - A promise that resolves to the created model instance. - * @throws {Error} - Throws an error if the data is invalid or if the creation process fails. - */ - async create(item) { - if (!isNonEmptyObject(item)) { - const message = `Failed to create [${this.entityName}]: data is required`; - this.log.error(message); - throw new Error(message); - } - - try { - // todo: catch ElectroDB validation errors and re-throws as ValidationError - // todo: validate associations - const record = await this.entity.create(item).go(); - return this.#createInstance(record); - } catch (error) { - this.log.error(`Failed to create [${this.entityName}]`, error); - throw error; - } - } - - /** - * Creates multiple entities in the collection and directly persists them to the database in - * a batch write operation. Batches are written in parallel and are limited to 25 items per batch. - * - * @async - * @param {Array} newItems - An array of data for the entities to be created. - * @param {BaseModel} [parent] - Optional parent entity that these items are associated with. - * @return {Promise<{ createdItems: BaseModel[], - * errorItems: { item: Object, error: ElectroValidationError }[] }>} - A promise that resolves to - * an object containing the created items and any items that failed validation. - * @throws {ValidationError} - Throws a validation error if any of the items has validation - * failures. - */ - async createMany(newItems, parent = null) { - if (!Array.isArray(newItems) || newItems.length === 0) { - const message = `Failed to create many [${this.entityName}]: items must be a non-empty array`; - this.log.error(message); - throw new Error(message); - } - - try { - const validatedItems = []; - const errorItems = []; - const createdItems = []; - - newItems.forEach((item) => { - try { - this.entity.put(item).params(); - validatedItems.push(item); - } catch (error) { - if (error instanceof ElectroValidationError) { - errorItems.push({ item, error: new ValidationError(error) }); - } - } - }); - - /** - * ElectroDB does not return the created items in the response for batch write operations. - * This listener intercepts the batch write requests and extracts the items before they - * are stored in the database. - * @param {Object} result - The result of the operation. - */ - const requestItemsListener = (result) => { - if (result?.type !== 'query' || result?.method !== 'batchWrite') { - return; - } - - result.params?.RequestItems[this.entity.model.table].forEach((putRequest) => { - createdItems.push(putRequest.PutRequest.Item); - }); - }; - - let records = []; - if (validatedItems.length > 0) { - const response = await this.entity.put(validatedItems).go( - { listeners: [requestItemsListener] }, - ); - records = this.#createInstances({ data: createdItems }); - - if (Array.isArray(response.unprocessed) && response.unprocessed.length > 0) { - this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); - } - } - - if (parent) { - records.forEach((record) => { - // eslint-disable-next-line no-underscore-dangle - record._cacheReference(parent.entity.model.name, parent); - }); - } - - return { createdItems: records, errorItems }; - } catch (error) { - this.log.error(`Failed to create many [${this.entityName}]`, error); - throw error; - } - } - - /** - * Updates a collection of entities in the database using a batch write (put) operation. - * - * @async - * @param {Array} items - An array of model instances to be updated. - * @return {Promise} - A promise that resolves when the update operation is complete. - * @throws {Error} - Throws an error if the update operation fails. - * @protected - */ - async _saveMany(items) { - if (!Array.isArray(items) || items.length === 0) { - const message = `Failed to save many [${this.entityName}]: items must be a non-empty array`; - this.log.error(message); - throw new Error(message); - } - - try { - const updates = items.map((item) => item.record); - const response = await this.entity.put(updates).go(); - - if (response.unprocessed) { - this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); - } - } catch (error) { - this.log.error(`Failed to save many [${this.entityName}]`, error); - throw error; - } - } -} - -export default BaseCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js b/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js new file mode 100755 index 00000000..663b0331 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js @@ -0,0 +1,450 @@ +/* + * 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, + isNonEmptyObject, + isObject, +} from '@adobe/spacecat-shared-utils'; + +import { ElectroValidationError } from 'electrodb'; + +import { createAccessors } from '../../util/accessor.utils.js'; +import ValidationError from '../../errors/validation.error.js'; +import { guardId } from '../../util/guards.js'; +import { + entityNameToAllPKValue, + isNonEmptyArray, + keyNamesToIndexName, + removeElectroProperties, +} from '../../util/util.js'; +import { INDEX_TYPES } from './constants.js'; + +function isValidParent(parent, child) { + if (!hasText(parent.entityName)) { + return false; + } + + const foreignKey = `${parent.entityName}Id`; + + return child.record?.[foreignKey] === parent.record?.[foreignKey]; +} + +/** + * Attempts to find an index name matching a generated name from the given keyNames. + * If no exact match is found, it progressively shortens the keyNames by removing the last one + * and tries again. If still no match, it tries the "all" index, and then "primary". + * + * @param {object} indexes - The available indexes, keyed by their names. + * @param {object} keys - The keys to find an index name for. + * @returns {object} The found index. + */ +function findIndexNameByKeys(indexes, keys) { + const keyNames = Object.keys(keys); + for (let { length } = keyNames; length > 0; length -= 1) { + const subKeyNames = keyNames.slice(0, length); + const candidateName = keyNamesToIndexName(subKeyNames); + if (indexes[candidateName]) { + return candidateName; + } + } + + if (indexes.all) { + return INDEX_TYPES.ALL; + } + + return INDEX_TYPES.PRIMARY; +} + +/** + * BaseCollection - A base class for managing collections of entities in the application. + * This class uses ElectroDB to interact with entities and provides common functionality + * for data operations. + * + * @class BaseCollection + * @abstract + */ +class BaseCollection { + /** + * Constructs an instance of BaseCollection. + * @constructor + * @param {Object} electroService - The ElectroDB service used for managing entities. + * @param {Object} entityRegistry - The registry holding entities, their schema and collection. + * @param {Object} schema - The schema for the entity. + * @param {Object} log - A log for capturing logging information. + */ + constructor(electroService, entityRegistry, schema, log) { + this.electroService = electroService; + this.entityRegistry = entityRegistry; + this.schema = schema; + this.log = log; + + this.clazz = this.schema.getModelClass(); + this.entityName = this.schema.getEntityName(); + this.idName = this.schema.getIdName(); + this.entity = electroService.entities[this.entityName]; + + this.#initializeCollectionMethods(); + } + + /** + * Initialize collection methods for each "by..." index defined in the entity schema. + * For each index that starts with "by", we: + * 1. Retrieve its composite pk and sk arrays from the schema. + * 2. Generate convenience methods for every prefix of the composite keys. + * For example, if the index keys are ['opportunityId', 'status', 'createdAt'], + * we create methods: + * - allByOpportunityId(...) / findByOpportunityId(...) + * - allByOpportunityIdAndStatus(...) / findByOpportunityIdAndStatus(...) + * - allByOpportunityIdAndStatusAndCreatedAt(...) / + * findByOpportunityIdAndStatusAndCreatedAt(...) + * + * Each generated method calls allByIndexKeys() or findByIndexKeys() with the appropriate keys. + * + * @private + */ + #initializeCollectionMethods() { + const accessorConfigs = this.schema.toAccessorConfigs(this, this.log); + createAccessors(accessorConfigs, this.log); + } + + /** + * Creates an instance of a model from a record. + * @private + * @param {Object} record - The record containing data to create the model instance. + * @returns {BaseModel|null} - Returns an instance of the model class if the data is valid, + * otherwise null. + */ + #createInstance(record) { + if (!isNonEmptyObject(record)) { + this.log.warn(`Failed to create instance of [${this.entityName}]: record is empty`); + return null; + } + // eslint-disable-next-line new-cap + return new this.clazz( + this.electroService, + this.entityRegistry, + this.schema, + record, + this.log, + ); + } + + /** + * Creates instances of models from a set of records. + * @private + * @param {Object} records - The records containing data to create the model instances. + * @returns {Array} - An array of instances of the model class. + */ + #createInstances(records) { + return records.map((record) => this.#createInstance(record)); + } + + #invalidateCache() { + this._accessorCache = {}; + } + + /** + * General method to query entities by index keys. This method is used by other + * query methods to perform the actual query operation. It will use the index keys + * to find the appropriate index and query the entities. The query result will be + * transformed into model instances. + * @private + * @param {Object} keys - The index keys to use for the query. + * @param {Object} options - Additional options for the query. + * @returns {Promise|null>} - The query result. + */ + async #queryByIndexKeys(keys, options = {}) { + if (!isNonEmptyObject(keys)) { + const message = `Failed to query [${this.entityName}]: keys are required`; + this.log.error(message); + throw new Error(message); + } + + if (!isObject(options)) { + const message = `Failed to query [${this.entityName}]: options must be an object`; + this.log.error(message); + throw new Error(message); + } + + const indexName = options.index || findIndexNameByKeys(this.entity.query, keys); + const index = this.entity.query[indexName]; + + if (!index) { + const message = `Failed to query [${this.entityName}]: index [${indexName}] not found`; + this.log.error(message); + throw new Error(message); + } + + const queryOptions = { + order: options.order || 'desc', + ...options.limit && { limit: options.limit }, + ...options.attributes && { attributes: options.attributes }, + }; + + let query = index(keys); + + if (isObject(options.between)) { + query = query.between( + { [options.between.attribute]: options.between.start }, + { [options.between.attribute]: options.between.end }, + ); + } + + const records = await query.go(queryOptions); + + if (options.limit === 1) { + if (records.data?.length === 0) { + return null; + } + return this.#createInstance(records.data[0]); + } else { + return this.#createInstances(records.data); + } + } + + /** + * Finds all entities in the collection. Requires an index named "all" with a partition key + * named "pk" with a static value of "ALL_". + * @param {Object} [sortKeys] - The sort keys to use for the query. + * @param {Object} [options] - Additional options for the query. + * @return {Promise|null>} + */ + async all(sortKeys = {}, options = {}) { + const keys = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys }; + return this.#queryByIndexKeys(keys, options); + } + + /** + * Finds entities by a set of index keys. Index keys are used to query entities by + * a specific index defined in the entity schema. The index keys must match the + * fields defined in the index. + * @param {Object} keys - The index keys to use for the query. + * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query. + * @return {Promise>} - A promise that resolves to an array of model instances. + * @throws {Error} - Throws an error if the index keys are not provided or if the index + * is not found. + * @async + */ + async allByIndexKeys(keys, options = {}) { + return this.#queryByIndexKeys(keys, options); + } + + /** + * Finds a single entity from the "all" index. Requires an index named "all" with a partition key + * named "pk" with a static value of "ALL_". + * @param {Object} [sortKeys] - The sort keys to use for the query. + * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query. + * @return {Promise|null>} + */ + async findByAll(sortKeys = {}, options = {}) { + if (!isObject(sortKeys)) { + const message = `Failed to find by all [${this.entityName}]: sort keys must be an object`; + this.log.error(message); + throw new Error(message); + } + + const keys = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys }; + return this.#queryByIndexKeys(keys, { ...options, index: INDEX_TYPES.ALL, limit: 1 }); + } + + /** + * Finds an entity by its ID. + * @async + * @param {string} id - The unique identifier of the entity to be found. + * @returns {Promise} - A promise that resolves to an instance of + * the model if found, otherwise null. + * @throws {Error} - Throws an error if the ID is not provided. + */ + async findById(id) { + guardId(this.idName, id, this.entityName); + + const record = await this.entity.get({ [this.idName]: id }).go(); + + return this.#createInstance(record?.data); + } + + /** + * Finds a single entity by index keys. + * @param {Object} keys - The index keys to use for the query. + * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query. + * @returns {Promise} - A promise that resolves to the model instance or null. + * @async + */ + async findByIndexKeys(keys, options = {}) { + return this.#queryByIndexKeys(keys, { ...options, limit: 1 }); + } + + /** + * Creates a new entity in the collection and directly persists it to the database. + * There is no need to call the save method (which is for updates only) after creating + * the entity. + * @async + * @param {Object} item - The data for the entity to be created. + * @returns {Promise} - A promise that resolves to the created model instance. + * @throws {Error} - Throws an error if the data is invalid or if the creation process fails. + */ + async create(item) { + if (!isNonEmptyObject(item)) { + const message = `Failed to create [${this.entityName}]: data is required`; + this.log.error(message); + throw new Error(message); + } + + try { + const record = await this.entity.create(item).go(); + const instance = this.#createInstance(record.data); + + this.#invalidateCache(); + + return instance; + } catch (error) { + this.log.error(`Failed to create [${this.entityName}]`, error); + throw error; + } + } + + /** + * Validates and batches items for batch operations. + * @private + * @param {Array} items - Items to be validated. + * @returns {Object} - An object containing validated items and error items. + */ + #validateItems(items) { + const validatedItems = []; + const errorItems = []; + + items.forEach((item) => { + try { + const { Item } = this.entity.put(item).params(); + validatedItems.push({ ...removeElectroProperties(Item), ...item }); + } catch (error) { + if (error instanceof ElectroValidationError) { + errorItems.push({ item, error: new ValidationError(error) }); + } + } + }); + + return { validatedItems, errorItems }; + } + + /** + * Creates multiple entities in the collection and directly persists them to the database in + * a batch write operation. Batches are written in parallel and are limited to 25 items per batch. + * + * @async + * @param {Array} newItems - An array of data for the entities to be created. + * @param {BaseModel} [parent] - Optional parent entity that these items are associated with. + * @return {Promise<{ createdItems: BaseModel[], + * errorItems: { item: Object, error: ValidationError }[] }>} - A promise that resolves to + * an object containing the created items and any items that failed validation. + * @throws {ValidationError} - Throws a validation error if any of the items has validation + * failures. + */ + async createMany(newItems, parent = null) { + if (!isNonEmptyArray(newItems)) { + const message = `Failed to create many [${this.entityName}]: items must be a non-empty array`; + this.log.error(message); + throw new Error(message); + } + + try { + const { validatedItems, errorItems } = this.#validateItems(newItems); + + if (validatedItems.length > 0) { + const response = await this.entity.put(validatedItems).go(); + + if (isNonEmptyArray(response?.unprocessed)) { + this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); + } + } + + const createdItems = this.#createInstances(validatedItems); + + if (isNonEmptyObject(parent)) { + createdItems.forEach((record) => { + if (!isValidParent(parent, record)) { + this.log.warn(`Failed to associate parent with child [${this.entityName}]: parent is invalid`); + return; + } + // eslint-disable-next-line no-underscore-dangle,no-param-reassign + record._accessorCache[`get${parent.schema.getModelName()}`] = parent; + }); + } + + this.#invalidateCache(); + + this.log.info(`Created ${createdItems.length} items for [${this.entityName}]`); + + return { createdItems, errorItems }; + } catch (error) { + this.log.error(`Failed to create many [${this.entityName}]`, error); + throw error; + } + } + + /** + * Updates a collection of entities in the database using a batch write (put) operation. + * + * @async + * @param {Array} items - An array of model instances to be updated. + * @return {Promise} - A promise that resolves when the update operation is complete. + * @throws {Error} - Throws an error if the update operation fails. + * @protected + */ + async _saveMany(items) { + if (!isNonEmptyArray(items)) { + const message = `Failed to save many [${this.entityName}]: items must be a non-empty array`; + this.log.error(message); + throw new Error(message); + } + + try { + const updates = items.map((item) => item.record); + const response = await this.entity.put(updates).go(); + + this.#invalidateCache(); + + if (response.unprocessed) { + this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); + } + } catch (error) { + this.log.error(`Failed to save many [${this.entityName}]`, error); + throw error; + } + } + + /** + * Removes all records of this entity based on the provided IDs. This will perform a batch + * delete operation. This operation does not remove dependent records. + * @param {Array} ids - An array of IDs to remove. + * @return {Promise} - A promise that resolves when the removal operation is complete. + * @throws {Error} - Throws an error if the IDs are not provided or if the + * removal operation fails. + */ + async removeByIds(ids) { + if (!isNonEmptyArray(ids)) { + const message = `Failed to remove [${this.entityName}]: ids must be a non-empty array`; + this.log.error(message); + throw new Error(message); + } + + this.log.info(`Removing ${ids.length} items for [${this.entityName}]`); + // todo: consider removing dependent records + + await this.entity.delete(ids.map((id) => ({ [this.idName]: id }))).go(); + + this.#invalidateCache(); + } +} + +export default BaseCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base.model.js b/packages/spacecat-shared-data-access/src/v2/models/base/base.model.js similarity index 51% rename from packages/spacecat-shared-data-access/src/v2/models/base.model.js rename to packages/spacecat-shared-data-access/src/v2/models/base/base.model.js index cd45adad..f0e6695c 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/base.model.js +++ b/packages/spacecat-shared-data-access/src/v2/models/base/base.model.js @@ -12,13 +12,16 @@ import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; -import Patcher from '../util/patcher.js'; +import { createAccessors } from '../../util/accessor.utils.js'; +import Patcher from '../../util/patcher.js'; import { capitalize, - entityNameToCollectionName, entityNameToIdName, - entityNameToReferenceMethodName, idNameToEntityName, -} from '../util/reference.js'; + idNameToEntityName, + isNonEmptyArray, +} from '../../util/util.js'; + +import Reference from './reference.js'; /** * Base - A base class for representing individual entities in the application. @@ -40,20 +43,28 @@ class BaseModel { * Constructs an instance of BaseModel. * @constructor * @param {Object} electroService - The ElectroDB service used for managing entities. - * @param {Object} modelFactory - A factory for creating model instances. + * @param {EntityRegistry} entityRegistry - The registry holding entities, their schema + * and collection. + * @param {Schema} schema - The schema for the entity. * @param {Object} record - The initial data for the entity instance. - * @param {Object} log - A logger for capturing logging information. + * @param {Object} log - A log for capturing logging information. */ - constructor(electroService, modelFactory, record, log) { - this.modelFactory = modelFactory; + constructor(electroService, entityRegistry, schema, record, log) { + this.electroService = electroService; + this.entityRegistry = entityRegistry; + this.schema = schema; this.record = record; - this.entityName = this.constructor.name.toLowerCase(); - this.entity = electroService.entities[this.entityName]; - this.idName = `${this.entityName}Id`; this.log = log; - this.referencesCache = {}; - this.patcher = new Patcher(this.entity, this.record); + this.entityName = schema.getEntityName(); + this.idName = entityNameToIdName(this.entityName); + + this.collection = entityRegistry.getCollection(schema.getCollectionName()); + this.entity = electroService.entities[this.entityName]; + + this.patcher = new Patcher(this.entity, this.schema, this.record); + + this._accessorCache = {}; this.#initializeReferences(); this.#initializeAttributes(); @@ -66,23 +77,16 @@ class BaseModel { * @private */ #initializeReferences() { - const { references } = this.entity.model.original; - if (!isNonEmptyObject(references)) { - return; - } - - for (const [type, refs] of Object.entries(references)) { - refs.forEach((ref) => { - const { target } = ref; - const methodName = entityNameToReferenceMethodName(target, type); + const references = this.schema.getReferences(); - this[methodName] = async () => this._fetchReference(type, target); - }); - } + references.forEach((reference) => { + const accessorConfigs = reference.toAccessorConfigs(this.entityRegistry, this); + createAccessors(accessorConfigs, this.log); + }); } #initializeAttributes() { - const { attributes } = this.entity.model.schema; + const attributes = this.schema.getAttributes(); if (!isNonEmptyObject(attributes)) { return; @@ -92,8 +96,9 @@ class BaseModel { const capitalized = capitalize(name); const getterMethodName = `get${capitalized}`; const setterMethodName = `set${capitalized}`; - const isReference = this.entity.model.original - .references?.belongs_to?.some((ref) => ref.target === idNameToEntityName(name)); + const isReference = this.schema + .getReferencesByType(Reference.TYPES.BELONGS_TO) + .some((ref) => ref.getTarget() === idNameToEntityName(name)); if (!this[getterMethodName] || name === this.idName) { this[getterMethodName] = () => this.record[name]; @@ -108,64 +113,43 @@ class BaseModel { } } - /** - * Gets a cached reference for the specified entity. - * @param {string} targetName - The name of the entity to fetch. - * @return {*} - */ - #getCachedReference(targetName) { - return this.referencesCache[targetName]; - } - - /** - * Caches a reference for the specified entity. This method is used to store - * fetched references to avoid redundant database queries. - * @param {string} targetName - The name of the entity to cache. - * @param {*} reference - The reference to cache. - * @private - */ - _cacheReference(targetName, reference) { - this.referencesCache[targetName] = reference; + #invalidateCache() { + this._accessorCache = {}; } - /** - * Fetches a reference for the specified entity. This method is used to fetch - * associated entities based on the type of relationship (belongs_to, has_one, has_many). - * The fetched references are cached to avoid redundant database queries. If the reference - * is already cached, it will be returned directly. - * References are defined in the entity model and are used to fetch associated entities. - * @async - * @param {string} type - The type of relationship (belongs_to, has_one, has_many). - * @param {string} targetName - The name of the entity to fetch. - * @return {Promise<*|null>} - A promise that resolves to the fetched reference or null if - * not found. - * @private - */ - async _fetchReference(type, targetName) { /* eslint-disable no-underscore-dangle */ - let result = this.#getCachedReference(targetName); - if (result) { - return result; - } - - const collectionName = entityNameToCollectionName(targetName); - const targetCollection = this.modelFactory.getCollection(collectionName); - - if (type === 'belongs_to' || type === 'has_one') { - const foreignKey = entityNameToIdName(targetName); - const id = this.record[foreignKey]; - if (!id) return null; - - result = await targetCollection.findById(id); - } else if (type === 'has_many') { - const foreignKey = entityNameToIdName(this.entityName); - result = await targetCollection.findByIndexKeys({ [foreignKey]: this.getId() }); - } + async #fetchDependents() { + const promises = []; + + const relationshipTypes = [ + Reference.TYPES.HAS_MANY, + Reference.TYPES.HAS_ONE, + ]; + + relationshipTypes.forEach((type) => { + const references = this.schema.getReferencesByType(type); + const targets = references.filter((reference) => reference.isRemoveDependents()); + + targets.forEach((reference) => { + const accessors = reference.toAccessorConfigs(this.entityRegistry, this); + const methodName = accessors[0].name; + promises.push( + this[methodName]() + .then((dependent) => { + if (isNonEmptyArray(dependent)) { + return dependent; + } else if (isNonEmptyObject(dependent)) { + return [dependent]; + } + + return null; + }), + ); + }); + }); - if (result) { - await this._cacheReference(targetName, result); - } + const results = await Promise.all(promises); - return result; + return results.flat().filter((dependent) => dependent !== null); } /** @@ -181,7 +165,7 @@ class BaseModel { * @returns {string} - The ISO string representing when the entity was created. */ getCreatedAt() { - return new Date(this.record.createdAt).toISOString(); + return this.record.createdAt; } /** @@ -189,20 +173,37 @@ class BaseModel { * @returns {string} - The ISO string representing when the entity was last updated. */ getUpdatedAt() { - return new Date(this.record.updatedAt).toISOString(); + return this.record.updatedAt; } /** - * Removes the current entity from the database. + * Removes the current entity from the database. This method also removes any dependent + * entities associated with the current entity. For example, if the current entity has + * a has_many relationship with another entity, the dependent entity will be removed. + * When adding a reference to an entity, the dependent entity will be removed if the + * removeDependentss flag is set to true in the reference definition. + * + * Dependents are removed by calling the remove method on each dependent entity, which in turn + * will also remove any dependent entities associated with the dependent entity. This may result + * in a cascade effect where multiple entities are removed. Consider the destructive + * and performance implications before using this method. * @async * @returns {Promise} - A promise that resolves to the current instance of the entity - * after it has been removed. + * after it and its dependents have been removed. * @throws {Error} - Throws an error if the removal fails. */ async remove() { try { - // todo: remove dependents (child associations) - await this.entity.remove({ [this.idName]: this.getId() }).go(); + const dependents = await this.#fetchDependents(); + const removePromises = dependents.map((dependent) => dependent.remove()); + removePromises.push(this.entity.remove({ [this.idName]: this.getId() }).go()); + + this.log.info(`Removing entity ${this.entityName} with ID ${this.getId()} and ${dependents.length} dependents`); + + await Promise.all(removePromises); + + this.#invalidateCache(); + return this; } catch (error) { this.log.error('Failed to remove record', error); @@ -221,14 +222,33 @@ class BaseModel { async save() { // todo: validate associations try { + this.log.info(`Saving entity ${this.entityName} with ID ${this.getId()}`); + await this.patcher.save(); - // todo: in case references are updated, clear or refresh references cache + this.#invalidateCache(); + return this; } catch (error) { this.log.error('Failed to save record', error); throw error; } } + + /** + * Converts the entity attributes to a JSON object. + * @returns {Object} - A JSON representation of the entity attributes. + */ + toJSON() { + const attributes = this.schema.getAttributes(); + + return Object.keys(attributes).reduce((json, key) => { + if (this.record[key] !== undefined) { + // eslint-disable-next-line no-param-reassign + json[key] = this.record[key]; + } + return json; + }, {}); + } } export default BaseModel; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/constants.js b/packages/spacecat-shared-data-access/src/v2/models/base/constants.js new file mode 100644 index 00000000..76611c0c --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/constants.js @@ -0,0 +1,17 @@ +/* + * 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. + */ +export const INDEX_TYPES = { + PRIMARY: 'primary', + ALL: 'all', + BELONGS_TO: 'belongs_to', + OTHER: 'other', +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/entity.registry.js b/packages/spacecat-shared-data-access/src/v2/models/base/entity.registry.js new file mode 100755 index 00000000..779b62ab --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/entity.registry.js @@ -0,0 +1,137 @@ +/* + * 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 { collectionNameToEntityName, decapitalize } from '../../util/util.js'; + +import ApiKeyCollection from '../api-key/api-key.collection.js'; +import AuditCollection from '../audit/audit.collection.js'; +import ConfigurationCollection from '../configuration/configuration.collection.js'; +import ExperimentCollection from '../experiment/experiment.collection.js'; +import ImportJobCollection from '../import-job/import-job.collection.js'; +import ImportUrlCollection from '../import-url/import-url.collection.js'; +import KeyEventCollection from '../key-event/key-event.collection.js'; +import OpportunityCollection from '../opportunity/opportunity.collection.js'; +import OrganizationCollection from '../organization/organization.collection.js'; +import SiteCandidateCollection from '../site-candidate/site-candidate.collection.js'; +import SiteCollection from '../site/site.collection.js'; +import SiteTopPageCollection from '../site-top-page/site-top-page.collection.js'; +import SuggestionCollection from '../suggestion/suggestion.collection.js'; + +import ApiKeySchema from '../api-key/api-key.schema.js'; +import AuditSchema from '../audit/audit.schema.js'; +import ConfigurationSchema from '../configuration/configuration.schema.js'; +import ExperimentSchema from '../experiment/experiment.schema.js'; +import ImportJobSchema from '../import-job/import-job.schema.js'; +import ImportUrlSchema from '../import-url/import-url.schema.js'; +import KeyEventSchema from '../key-event/key-event.schema.js'; +import OpportunitySchema from '../opportunity/opportunity.schema.js'; +import OrganizationSchema from '../organization/organization.schema.js'; +import SiteSchema from '../site/site.schema.js'; +import SiteCandidateSchema from '../site-candidate/site-candidate.schema.js'; +import SiteTopPageSchema from '../site-top-page/site-top-page.schema.js'; +import SuggestionSchema from '../suggestion/suggestion.schema.js'; + +/** + * EntityRegistry - A registry class responsible for managing entities, their schema and collection. + * + * @class EntityRegistry + */ +class EntityRegistry { + static entities = {}; + + /** + * Constructs an instance of EntityRegistry. + * @constructor + * @param {Object} service - The ElectroDB service instance used to manage entities. + * @param {Object} log - A logger for capturing and logging information. + */ + constructor(service, log) { + this.service = service; + this.log = log; + this.collections = new Map(); + + this.#initialize(); + } + + /** + * Initializes the collections managed by the EntityRegistry. + * This method creates instances of each collection and stores them in an internal map. + * @private + */ + #initialize() { + Object.values(EntityRegistry.entities).forEach(({ collection: Collection, schema }) => { + const collection = new Collection(this.service, this, schema, this.log); + this.collections.set(Collection.name, collection); + }); + + this.#logIndexes(); + } + + #logIndexes() { + // reduce collection schema indexes into object + const indexes = Object.values(EntityRegistry.entities).reduce((acc, { schema }) => { + acc[schema.getEntityName()] = schema.indexes; + return acc; + }, {}); + + this.log.debug('Indexes:', JSON.stringify(indexes, null, 2)); + } + + /** + * Gets a collection instance by its name. + * @param {string} collectionName - The name of the collection to retrieve. + * @returns {Object} - The requested collection instance. + * @throws {Error} - Throws an error if the collection with the specified name is not found. + */ + getCollection(collectionName) { + const collection = this.collections.get(collectionName); + if (!collection) { + throw new Error(`Collection ${collectionName} not found`); + } + return collection; + } + + getCollections() { + const collections = {}; + for (const [key, value] of this.collections) { + collections[collectionNameToEntityName(key)] = value; + } + return collections; + } + + static getEntities() { + return Object.keys(this.entities).reduce((acc, key) => { + acc[key] = this.entities[key].schema.toElectroDBSchema(); + return acc; + }, {}); + } + + static registerEntity(schema, collection) { + this.entities[decapitalize(schema.getEntityName())] = { schema, collection }; + } +} + +EntityRegistry.registerEntity(ApiKeySchema, ApiKeyCollection); +EntityRegistry.registerEntity(AuditSchema, AuditCollection); +EntityRegistry.registerEntity(ConfigurationSchema, ConfigurationCollection); +EntityRegistry.registerEntity(ExperimentSchema, ExperimentCollection); +EntityRegistry.registerEntity(ImportJobSchema, ImportJobCollection); +EntityRegistry.registerEntity(ImportUrlSchema, ImportUrlCollection); +EntityRegistry.registerEntity(KeyEventSchema, KeyEventCollection); +EntityRegistry.registerEntity(OpportunitySchema, OpportunityCollection); +EntityRegistry.registerEntity(OrganizationSchema, OrganizationCollection); +EntityRegistry.registerEntity(SiteSchema, SiteCollection); +EntityRegistry.registerEntity(SiteCandidateSchema, SiteCandidateCollection); +EntityRegistry.registerEntity(SiteTopPageSchema, SiteTopPageCollection); +EntityRegistry.registerEntity(SuggestionSchema, SuggestionCollection); + +export default EntityRegistry; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/base/index.d.ts new file mode 100644 index 00000000..b759ea09 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/index.d.ts @@ -0,0 +1,83 @@ +/* + * 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 { ValidationError } from '../../errors'; + +export interface BaseModel { + getCreatedAt(): string; + getId(): string; + getUpdatedAt(): string; + remove(): Promise; + save(): Promise; + toJSON(): object; +} + +export interface MultiStatusCreateResult { + createdItems: T[], + errorItems: { item: object, error: ValidationError }[], +} + +export interface QueryOptions { + index?: string; + limit?: number; + sort?: string; + attributes?: string[]; +} + +export interface BaseCollection { + all(sortKeys?: object, options?: QueryOptions): Promise; + allByIndexKeys(keys: object, options?: QueryOptions): Promise; + create(item: object): Promise; + createMany(items: object[]): Promise>; + findByAll(sortKeys?: object, options?: QueryOptions): Promise; + findById(id: string): Promise; + findByIndexKeys(indexKeys: object): Promise; + removeByIds(ids: string[]): Promise; +} + +export interface EntityRegistry { + getCollection(collectionName: string): BaseCollection; + getCollections(): BaseCollection[]; + getEntities(): object; + registerEntity(schema: object, collection: BaseCollection): void; +} + +export interface Reference { + getSortKeys(): string[]; + getTarget(): string; + getType(): string; + isRemoveDependents(): boolean; +} + +export interface Schema { + getAttribute(name: string): object; + getAttributes(): object; + getCollectionName(): string; + getEntityName(): string; + getIdName(): string; + getIndexes(): object; + getIndexKeys(indexName: string): string[]; + getModelClass(): object; + getModelName(): string; + getReferences(): Reference[]; + getReferencesByType(referenceType: string): Reference[]; + getReferenceByTypeAndTarget(referenceType: string, target: string): Reference | undefined; +} + +export interface SchemaBuilder { + addAttribute(name: string, data: object): SchemaBuilder; + addAllIndexWithComposite(...attributeNames: string[]): SchemaBuilder + addAllIndexWithTemplateField(fieldName: string, template: string): SchemaBuilder; + addIndex(name: string, partitionKey: object, sortKey: object): SchemaBuilder; + addReference(referenceType: string, entityName: string, sortKeys?: string[]): SchemaBuilder; + build(): Schema; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/index.js b/packages/spacecat-shared-data-access/src/v2/models/base/index.js new file mode 100644 index 00000000..765ca21f --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/index.js @@ -0,0 +1,27 @@ +/* + * 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.model.js'; +import BaseCollection from './base.collection.js'; +import EntityRegistry from './entity.registry.js'; +import Reference from './reference.js'; +import Schema from './schema.js'; +import SchemaBuilder from './schema.builder.js'; + +export { + BaseModel, + BaseCollection, + EntityRegistry, + Reference, + Schema, + SchemaBuilder, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/reference.js b/packages/spacecat-shared-data-access/src/v2/models/base/reference.js new file mode 100644 index 00000000..0e2519eb --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/reference.js @@ -0,0 +1,159 @@ +/* + * 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 { + entityNameToCollectionName, + entityNameToIdName, + isNonEmptyArray, + keyNamesToMethodName, + referenceToBaseMethodName, +} from '../../util/util.js'; + +class Reference { + static TYPES = { + BELONGS_TO: 'belongs_to', + HAS_MANY: 'has_many', + HAS_ONE: 'has_one', + }; + + static fromJSON(json) { + return new Reference(json.type, json.target, json.options); + } + + static isValidType(type) { + return Object.values(Reference.TYPES).includes(type); + } + + constructor(type, target, options = {}) { + if (!Reference.isValidType(type)) { + throw new Error(`Invalid reference type: ${type}`); + } + + if (!hasText(target)) { + throw new Error('Invalid target'); + } + + this.type = type; + this.target = target; + this.options = options; + } + + getSortKeys() { + return this.options.sortKeys; + } + + getTarget() { + return this.target; + } + + getType() { + return this.type; + } + + isRemoveDependents() { + return this.options.removeDependents; + } + + toAccessorConfigs(registry, entity) { + const { log } = registry; + const accessorConfigs = []; + + const target = this.getTarget(); + const type = this.getType(); + + const baseMethodName = referenceToBaseMethodName(this); + const collectionName = entityNameToCollectionName(target); + const targetCollection = registry.getCollection(collectionName); + + switch (type) { + case Reference.TYPES.BELONGS_TO: { + const foreignKeyName = entityNameToIdName(target); + const foreignKeyValue = entity.record[foreignKeyName]; + + // belongs_to: direct findById + accessorConfigs.push({ + name: baseMethodName, + requiredKeys: [], + foreignKey: { name: foreignKeyName, value: foreignKeyValue }, + byId: true, + }); + break; + } + + case Reference.TYPES.HAS_ONE: { + const foreignKeyName = entityNameToIdName(entity.entityName); + const foreignKeyValue = entity.getId(); + + // has_one yields a single record. + accessorConfigs.push({ + name: baseMethodName, + requiredKeys: [], + foreignKey: { name: foreignKeyName, value: foreignKeyValue }, + }); + break; + } + + case Reference.TYPES.HAS_MANY: { + const foreignKeyName = entityNameToIdName(entity.entityName); + const foreignKeyValue = entity.getId(); + + // has_many yields multiple records. + accessorConfigs.push({ + name: baseMethodName, + requiredKeys: [], + all: true, + foreignKey: { name: foreignKeyName, value: foreignKeyValue }, + }); + + const belongsToRef = targetCollection.schema.getReferenceByTypeAndTarget( + Reference.TYPES.BELONGS_TO, + entity.schema.getModelName(), + ); + + if (!belongsToRef) { + log.warn(`Reciprocal reference not found for ${entity.schema.getModelName()} to ${target}`); + break; + } + + const sortKeys = belongsToRef.getSortKeys(); + if (!isNonEmptyArray(sortKeys)) { + log.debug(`No sort keys defined for ${entity.schema.getModelName()} to ${target}`); + break; + } + + for (let i = 1; i <= sortKeys.length; i += 1) { + const subset = sortKeys.slice(0, i); + accessorConfigs.push({ + name: keyNamesToMethodName(subset, `${baseMethodName}By`), + requiredKeys: subset, + all: true, + foreignKey: { name: foreignKeyName, value: foreignKeyValue }, + }); + } + + break; + } + + default: + throw new Error(`Unsupported reference type: ${type}`); + } + + return accessorConfigs.map((config) => ({ + ...config, + collection: targetCollection, + context: entity, + })); + } +} + +export default Reference; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/schema.builder.js b/packages/spacecat-shared-data-access/src/v2/models/base/schema.builder.js new file mode 100755 index 00000000..ff7cbf44 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/schema.builder.js @@ -0,0 +1,420 @@ +/* + * 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, isInteger, isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import { v4 as uuid, validate as uuidValidate } from 'uuid'; + +import { + capitalize, + decapitalize, + entityNameToAllPKValue, + entityNameToIdName, isNonEmptyArray, +} from '../../util/util.js'; + +import { INDEX_TYPES } from './constants.js'; +import BaseModel from './base.model.js'; +import BaseCollection from './base.collection.js'; +import Reference from './reference.js'; +import Schema from './schema.js'; + +const DEFAULT_SERVICE_NAME = 'SpaceCat'; + +/** + * ID attribute configuration object. + * Ensures a UUID-based "primary key". + * @type {object} + */ +const ID_ATTRIBUTE_DATA = { + type: 'string', + required: true, + readOnly: true, + // https://electrodb.dev/en/modeling/attributes/#default + default: () => uuid(), + // https://electrodb.dev/en/modeling/attributes/#attribute-validation + validate: (value) => uuidValidate(value), +}; + +/** + * CreatedAt attribute configuration object. + * Automatically sets to current date/time at creation. + * @type {object} + */ +const CREATED_AT_ATTRIBUTE_DATA = { + type: 'string', + readOnly: true, + required: true, + default: () => new Date().toISOString(), +}; + +/** + * UpdatedAt attribute configuration object. + * Automatically updates to current date/time whenever the entity is modified. + * @type {object} + */ +const UPDATED_AT_ATTRIBUTE_DATA = { + type: 'string', + required: true, + readOnly: true, + watch: '*', + default: () => new Date().toISOString(), + set: () => new Date().toISOString(), +}; + +/** Certain index names (primary, all) are reserved and cannot be reused. */ +const RESERVED_INDEX_NAMES = [INDEX_TYPES.PRIMARY, INDEX_TYPES.ALL]; + +/** + * Constructs a fully qualified index name. + * @param {string} service - The name of the service. + * @param {string} entity - The name of the entity. + * @param {string} name - The index name (e.g., 'all', 'byForeignKey'). + * @returns {string} The fully qualified index name. + */ +const createdIndexName = (service, entity, name) => `${service.toLowerCase()}-data-${entity}-${name}`; + +/** + * Sorts an indexes object by its keys alphabetically. + * @param {object} indexes - An object whose keys are index names and values are index definitions. + * @returns {object} A new object with the same entries, but keys sorted alphabetically. + */ +const sortIndexes = (indexes) => Object.fromEntries( + Object.entries(indexes).sort((a, b) => a[0].localeCompare(b[0])), +); + +/** + * Assigns GSI field names to indexes that don't have them yet. + * Ensures that if an "all" index exists, it uses gsi1 (already assigned) + * and other indexes continue numbering from gsi2 onwards. + * + * @param {object} indexes - Object of indexes that require naming. + * @param {object|null} all - The "all" index object if present, null otherwise. + */ +const numberGSIsIndexes = (indexes, all) => { + // if there's an "all" index, we start indexing subsequent GSIs from 2, + // because "all" index already occupies gsi1. + // if no "all" index exists, start from 1. + let gsiCounter = isNonEmptyObject(all) ? 1 : 0; + + Object.values(indexes).forEach((index) => { /* eslint-disable no-param-reassign */ + // only assign new field names and number through if none are provided. + if (!index.pk.field || !index.sk.field) { + gsiCounter += 1; + } + + index.pk.field = index.pk.field || `gsi${gsiCounter}pk`; + index.sk.field = index.sk.field || `gsi${gsiCounter}sk`; + }); +}; + +/** + * The SchemaBuilder class allows for constructing a schema definition + * including attributes, indexes, and references to other entities. + * Index ordering is enforced at build time for deterministic output: + * - primary index first + * - "all" index second (if present) + * - all "belongs_to" indexes sorted alphabetically next + * - all "other" indexes sorted alphabetically last + */ +class SchemaBuilder { + /** + * Creates a new SchemaBuilder instance. + * + * @param {BaseModel} modelClass - The model class for this entity. + * @param {BaseCollection} collectionClass - The collection class for this entity. + * @param {number} schemaVersion - A positive integer representing the schema's version. + * @throws {Error} If entityName is not a non-empty string. + * @throws {Error} If schemaVersion is not a positive integer. + * @throws {Error} If serviceName is not a non-empty string. + */ + constructor(modelClass, collectionClass, schemaVersion = 1) { + if (!modelClass || !(modelClass.prototype instanceof BaseModel)) { + throw new Error('modelClass must be a subclass of BaseModel.'); + } + + if (!collectionClass || !(collectionClass.prototype instanceof BaseCollection)) { + throw new Error('collectionClass must be a subclass of BaseCollection.'); + } + + if (!isInteger(schemaVersion) || schemaVersion < 1) { + throw new Error('schemaVersion is required and must be a positive integer.'); + } + + this.modelClass = modelClass; + this.collectionClass = collectionClass; + this.schemaVersion = schemaVersion; + this.entityName = modelClass.name; + this.serviceName = DEFAULT_SERVICE_NAME; + + this.idName = entityNameToIdName(this.entityName); + + this.rawIndexes = { + primary: null, + all: null, + belongs_to: {}, + other: {}, + }; + + this.attributes = {}; + + // will be populated by build() from rawIndexes + this.indexes = {}; + + // this is not part of the ElectroDB schema spec, but we use it to store reference data + this.references = []; + + this.#initialize(); + } + + #initialize() { + this.addAttribute(this.idName, ID_ATTRIBUTE_DATA); + this.addAttribute('createdAt', CREATED_AT_ATTRIBUTE_DATA); + this.addAttribute('updatedAt', UPDATED_AT_ATTRIBUTE_DATA); + // todo: add createdBy, updatedBy and auto-set from auth context + + // set up the primary index directly + // primary index fields are fixed and known upfront + this.rawIndexes.primary = { + pk: { field: 'pk', composite: [this.idName] }, + sk: { field: 'sk', composite: [] }, + }; + } + + #internalAddIndex(name, partitionKey, sortKey, type) { + const indexFullName = createdIndexName(this.serviceName, this.entityName, name); + + // store index config without assigning fields yet + // the fields will be assigned in build phase based on sorting and presence of "all" index + this.rawIndexes[type][name] = { + ...(indexFullName && { index: indexFullName }), + pk: { ...partitionKey }, + sk: { ...sortKey }, + }; + } + + /** + * Adds a new attribute to the schema definition. + * + * @param {string} name - The attribute name. + * @param {object} data - The attribute definition (type, required, validation, etc.). + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If name is not non-empty or data is not an object. + */ + addAttribute(name, data) { + if (!hasText(name)) { + throw new Error('Attribute name is required and must be non-empty.'); + } + + if (!isNonEmptyObject(data)) { + throw new Error(`Attribute data for "${name}" is required and must be a non-empty object.`); + } + + this.attributes[name] = data; + + return this; + } + + /** + * Adds an "all" index based on composite attributes. + * The "all" index is a special index listing all entities, sorted by given attributes. + * Useful for global queries across all entities of this type. + * Will overwrite any existing "all" index. + * + * @param {...string} attributeNames - The attribute names forming the composite sort key. + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If no attribute names are provided. + */ + addAllIndexWithComposite(...attributeNames) { + if (attributeNames.length === 0) { + throw new Error('At least one composite attribute name is required.'); + } + + this.rawIndexes.all = { + index: createdIndexName(this.serviceName, this.entityName, INDEX_TYPES.ALL), + pk: { field: 'gsi1pk', template: entityNameToAllPKValue(this.entityName) }, + sk: { field: 'gsi1sk', composite: attributeNames }, + }; + + return this; + } + + /** + * Adds an "all" index with a template-based sort key. + * Useful if a single value template defines how entries are sorted. + * + * @param {string} fieldName - The sort key field name. + * @param {string} template - A template string defining how to generate the sort key value. + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If fieldName or template are not valid strings. + */ + addAllIndexWithTemplateField(fieldName, template) { + if (!hasText(fieldName)) { + throw new Error('fieldName is required and must be a non-empty string.'); + } + + if (!hasText(template)) { + throw new Error('template is required and must be a non-empty string.'); + } + + this.rawIndexes.all = { + index: createdIndexName(this.serviceName, this.entityName, 'all'), + pk: { field: 'gsi1pk', template: entityNameToAllPKValue(this.entityName) }, + sk: { field: fieldName, template }, + }; + + return this; + } + + /** + * Adds a generic secondary index (GSI). + * + * @param {string} name - The index name. Cannot be 'primary' or 'all'. + * @param {object} partitionKey - The partition key definition + * (e.g., { composite: [attributeName] }). + * @param {object} sortKey - The sort key definition. + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If index name is reserved or pk/sk configs are invalid. + */ + addIndex(name, partitionKey, sortKey) { + if (!hasText(name)) { + throw new Error('Index name is required and must be a non-empty string.'); + } + + if (RESERVED_INDEX_NAMES.includes(name)) { + throw new Error(`Index name "${name}" is reserved.`); + } + + if (!isNonEmptyObject(partitionKey)) { + throw new Error('Partition key configuration (pk) is required and must be a non-empty object.'); + } + + if (!isNonEmptyObject(sortKey)) { + throw new Error('Sort key configuration (sk) is required and must be a non-empty object.'); + } + + this.#internalAddIndex(name, partitionKey, sortKey, INDEX_TYPES.OTHER); + + return this; + } + + /** + * Adds a reference to another entity, potentially creating a belongs_to index. + * + * @param {string} type - One of Reference.TYPES (BELONGS_TO, HAS_MANY, HAS_ONE). + * @param {string} entityName - The referenced entity name. + * @param {Array} [sortKeys=['updatedAt']] - The attributes to form the sort key. + * @param {object} [options] - Additional reference options. + * @param {boolean} [options.required=true] - Whether the reference is required. Only applies to + * BELONGS_TO references. + * @param {boolean} [options.removeDependents=false] - Whether to remove dependent entities + * on delete. Only applies to HAS_MANY and HAS_ONE references. + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If type or entityName are invalid. + */ + addReference(type, entityName, sortKeys = [], options = {}) { + if (!Reference.isValidType(type)) { + throw new Error(`Invalid referenceType: "${type}".`); + } + + if (!hasText(entityName)) { + throw new Error('entityName for reference is required and must be a non-empty string.'); + } + const reference = { + type, + target: entityName, + options: { sortKeys }, + }; + + if ([ + Reference.TYPES.HAS_MANY, + Reference.TYPES.HAS_ONE, + ].includes(type)) { + reference.options.removeDependents = options.removeDependents ?? false; + } + + if (type === Reference.TYPES.BELONGS_TO) { + reference.options.required = options.required ?? true; + + // for a BELONGS_TO reference, we add a foreign key attribute + // and a corresponding "belongs_to" index to facilitate lookups by that foreign key. + const foreignKeyName = entityNameToIdName(entityName); + + this.addAttribute(foreignKeyName, { + type: 'string', + required: reference.options.required, + validate: ( + value, + ) => (reference.options.required ? uuidValidate(value) : !value || uuidValidate(value)), + }); + + this.#internalAddIndex( + `by${capitalize(foreignKeyName)}`, + { composite: [decapitalize(foreignKeyName)] }, + { composite: isNonEmptyArray(sortKeys) ? sortKeys : ['updatedAt'] }, + INDEX_TYPES.BELONGS_TO, + ); + } + + this.references.push(Reference.fromJSON(reference)); + + return this; + } + + /** + * Builds the final indexes object by: + * - Sorting and merging belongs_to and other indexes + * - Assigning GSI fields to indexes after final order is determined + * + * @private + */ + #buildIndexes() { + // eslint-disable-next-line camelcase + const { belongs_to, other } = this.rawIndexes; + + // belongs_to indexes come before other indexes + const indexes = { + ...sortIndexes(belongs_to), + ...sortIndexes(other), + }; + + numberGSIsIndexes(indexes, this.rawIndexes.all); + + this.indexes = { + primary: this.rawIndexes.primary, + ...(this.rawIndexes.all && { all: this.rawIndexes.all }), + ...indexes, + }; + } + + /** + * Finalizes the schema by building and ordering indexes. + * + * @returns {object} The fully constructed schema object. + */ + build() { + this.#buildIndexes(); + + return new Schema( + this.modelClass, + this.collectionClass, + { + serviceName: this.serviceName, + schemaVersion: this.schemaVersion, + attributes: this.attributes, + indexes: this.indexes, + references: this.references, + }, + ); + } +} + +export default SchemaBuilder; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/schema.js b/packages/spacecat-shared-data-access/src/v2/models/base/schema.js new file mode 100644 index 00000000..e6cb3b75 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/schema.js @@ -0,0 +1,283 @@ +/* + * 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, isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import { + classExtends, + entityNameToCollectionName, + entityNameToIdName, + isNonEmptyArray, + isPositiveInteger, + keyNamesToMethodName, + modelNameToEntityName, +} from '../../util/util.js'; + +import BaseCollection from './base.collection.js'; +import BaseModel from './base.model.js'; +import { INDEX_TYPES } from './constants.js'; +import Reference from './reference.js'; + +class Schema { + /** + * Constructs a new Schema instance. + * @constructor + * @param {BaseModel} modelClass - The class representing the model. + * @param {BaseCollection} collectionClass - The class representing the model collection. + * @param {object} rawSchema - The raw schema data. + * @param {string} rawSchema.serviceName - The name of the service. + * @param {number} rawSchema.schemaVersion - The version of the schema. + * @param {object} rawSchema.attributes - The attributes of the schema. + * @param {object} rawSchema.indexes - The indexes of the schema. + * @param {Reference[]} [rawSchema.references] - The references of the schema. + */ + constructor( + modelClass, + collectionClass, + rawSchema, + ) { + this.modelClass = modelClass; + this.collectionClass = collectionClass; + + this.serviceName = rawSchema.serviceName; + this.schemaVersion = rawSchema.schemaVersion; + this.attributes = rawSchema.attributes; + this.indexes = rawSchema.indexes; + this.references = rawSchema.references || []; + + this.#validateSchema(); + } + + #validateSchema() { + if (!classExtends(this.modelClass, BaseModel)) { + throw new Error('Model class must extend BaseModel'); + } + + if (!classExtends(this.collectionClass, BaseCollection)) { + throw new Error('Collection class must extend BaseCollection'); + } + + if (!hasText(this.serviceName)) { + throw new Error('Schema must have a service name'); + } + + if (!isPositiveInteger(this.schemaVersion)) { + throw new Error('Schema version must be a positive integer'); + } + + if (!isNonEmptyObject(this.attributes)) { + throw new Error('Schema must have attributes'); + } + + if (!isNonEmptyObject(this.indexes)) { + throw new Error('Schema must have indexes'); + } + + if (!Array.isArray(this.references)) { + throw new Error('References must be an array'); + } + } + + getAttribute(name) { + return this.attributes[name]; + } + + getAttributes() { + return this.attributes; + } + + getCollectionName() { + return this.collectionClass.name; + } + + getEntityName() { + return modelNameToEntityName(this.getModelName()); + } + + getIdName() { + return entityNameToIdName(this.getModelName()); + } + + /** + * Returns a data structure describing all index-based accessors (like allByX, findByX). + * This can then be used by BaseCollection to create methods without duplicating logic. + * @return {Array<{indexName: string, keySets: string[][]}>} + * Example: [ + * { indexName: 'byOpportunityId', keySets: [['opportunityId'], ['opportunityId','status']] }, + * { indexName: 'byStatusAndCreatedAt', keySets: [['status'],['status','createdAt']] } + * ] + */ + getIndexAccessors() { + const indexes = this.getIndexes([INDEX_TYPES.PRIMARY]); + const result = []; + + Object.keys(indexes).forEach((indexName) => { + const indexKeys = this.getIndexKeys(indexName); + + if (!isNonEmptyArray(indexKeys)) return; + + const keySets = []; + for (let i = 1; i <= indexKeys.length; i += 1) { + keySets.push(indexKeys.slice(0, i)); + } + + result.push({ indexName, keySets }); + }); + + return result; + } + + getIndexByName(indexName) { + return this.indexes[indexName]; + } + + /** + * Returns the indexes for the schema. By default, this returns all indexes. + * You can use the `exclude` parameter to exclude certain indexes. + * @param {Array} [exclude] - One of the INDEX_TYPES values. + * @return {object} The indexes. + */ + getIndexes(exclude) { + if (!Array.isArray(exclude)) { + return this.indexes; + } + + return Object.keys(this.indexes).reduce((acc, indexName) => { + const index = this.indexes[indexName]; + + if (!exclude.includes(indexName)) { + acc[indexName] = index; + } + + return acc; + }, {}); + } + + getIndexKeys(indexName) { + const index = this.getIndexByName(indexName); + + if (!isNonEmptyObject(index)) { + return []; + } + + const pkKeys = Array.isArray(index.pk?.facets) ? index.pk.facets : []; + const skKeys = Array.isArray(index.sk?.facets) ? index.sk.facets : [index.sk?.field]; + + return [...pkKeys, ...skKeys]; + } + + getModelClass() { + return this.modelClass; + } + + getModelName() { + return this.modelClass.name; + } + + /** + * Given a type and a target model name, returns the reciprocal reference if it exists. + * For example, if we have a has_many reference from Foo to Bar, this method can help find + * the belongs_to reference in Bar that points back to Foo. + * @param {EntityRegistry} registry - The entity registry. + * @param {Reference} reference - The reference to find the reciprocal for. + * @return {Reference|null} - The reciprocal reference or null if not found. + */ + getReciprocalReference(registry, reference) { + const target = reference.getTarget(); + const type = reference.getType(); + + if (type !== Reference.TYPES.HAS_MANY) { + return null; + } + + const targetSchema = registry.getCollection(entityNameToCollectionName(target)).schema; + + return targetSchema.getReferenceByTypeAndTarget( + Reference.TYPES.BELONGS_TO, + this.getModelName(), + ); + } + + getReferences() { + return this.references; + } + + getReferencesByType(type) { + return this.references.filter((ref) => ref.type === type); + } + + getReferenceByTypeAndTarget(type, target) { + return this.references.find((ref) => ref.type === type && ref.target === target); + } + + getServiceName() { + return this.serviceName; + } + + getVersion() { + return this.schemaVersion; + } + + toAccessorConfigs(entity, log) { + const indexAccessors = this.getIndexAccessors(); + const accessorConfigs = []; + + indexAccessors.forEach(({ indexName, keySets }) => { + // generate a method for each prefix of the keySets array + // for example, if keySets = ['opportunityId', 'status'], we create: + // allByOpportunityId(...) + // findByOpportunityId(...) + // allByOpportunityIdAndStatus(...) + // findByOpportunityIdAndStatus(...) + keySets.forEach((subset) => { + accessorConfigs.push({ + context: entity, + collection: entity, + name: keyNamesToMethodName(subset, 'allBy'), + requiredKeys: subset, + all: true, + }); + + accessorConfigs.push({ + context: entity, + collection: entity, + name: keyNamesToMethodName(subset, 'findBy'), + requiredKeys: subset, + }); + + log.info(`Created accessors for index [${indexName}] with keys [${subset.join(', ')}]`); + }); + }); + + return accessorConfigs; + } + + /** + * Transforms the stored schema model into a format directly usable by ElectroDB. + * Here, you could do any final adjustments or transformations needed before returning. + * + * @returns {object} ElectroDB-compatible schema. + */ + toElectroDBSchema() { + return { + model: { + entity: this.getModelName(), + version: String(this.getVersion()), + service: this.getServiceName(), + }, + attributes: this.attributes, + indexes: this.indexes, + }; + } +} + +export default Schema; diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.collection.js b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.collection.js new file mode 100755 index 00000000..5914d44b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.collection.js @@ -0,0 +1,39 @@ +/* + * 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 { incrementVersion, sanitizeIdAndAuditFields } from '../../util/util.js'; +import BaseCollection from '../base/base.collection.js'; + +/** + * ConfigurationCollection - A collection class responsible for managing Configuration entities. + * Extends the BaseCollection to provide specific methods for interacting with + * Configuration records. + * + * @class ConfigurationCollection + * @extends BaseCollection + */ +class ConfigurationCollection extends BaseCollection { + async create(data) { + const latestConfiguration = await this.findLatest(); + const version = latestConfiguration ? incrementVersion(latestConfiguration.getVersion()) : 1; + const sanitizedData = sanitizeIdAndAuditFields('Organization', data); + sanitizedData.version = version; + + return super.create(sanitizedData); + } + + async findLatest() { + return this.findByAll({}, { order: 'desc' }); + } +} + +export default ConfigurationCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.model.js b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.model.js new file mode 100644 index 00000000..dcecc626 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.model.js @@ -0,0 +1,160 @@ +/* + * 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 { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import { sanitizeIdAndAuditFields } from '../../util/util.js'; +import BaseModel from '../base/base.model.js'; + +/** + * Configuration - A class representing an Configuration entity. + * Provides methods to access and manipulate Configuration-specific data. + * + * @class Configuration + * @extends BaseModel + */ +class Configuration extends BaseModel { + // add your custom methods or overrides here + + getHandler(type) { + return this.getHandlers()?.[type]; + } + + addHandler = (type, handlerData) => { + const handlers = this.getHandlers() || {}; + handlers[type] = { ...handlerData }; + + this.setHandlers(handlers); + }; + + getSlackRoleMembersByRole(role) { + return this.getSlackRoles()?.[role] || []; + } + + getEnabledSiteIdsForHandler(type) { + return this.getHandler(type)?.enabled?.sites || []; + } + + isHandlerEnabledForSite(type, site) { + const handler = this.getHandlers()?.[type]; + if (!handler) return false; + + const siteId = site.getId(); + const orgId = site.getOrganizationId(); + + if (handler.enabled) { + const sites = handler.enabled.sites || []; + const orgs = handler.enabled.orgs || []; + return sites.includes(siteId) || orgs.includes(orgId); + } + + if (handler.disabled) { + const sites = handler.disabled.sites || []; + const orgs = handler.disabled.orgs || []; + return !(sites.includes(siteId) || orgs.includes(orgId)); + } + + return handler.enabledByDefault; + } + + isHandlerEnabledForOrg(type, org) { + const handler = this.getHandlers()?.[type]; + if (!handler) return false; + + const orgId = org.getId(); + + if (handler.enabled) { + return handler.enabled.orgs?.includes(orgId); + } + + if (handler.disabled) { + return !handler.disabled.orgs?.includes(orgId); + } + + return handler.enabledByDefault; + } + + #updatedHandler(type, entityId, enabled, entityKey) { + const handlers = this.getHandlers(); + const handler = handlers?.[type]; + + if (!isNonEmptyObject(handler)) return; + + if (!isNonEmptyObject(handler.disabled)) { + handler.disabled = { orgs: [], sites: [] }; + } + + if (!isNonEmptyObject(handler.enabled)) { + handler.enabled = { orgs: [], sites: [] }; + } + + if (enabled) { + if (handler.enabledByDefault) { + handler.disabled[entityKey] = handler.disabled[entityKey] + .filter((id) => id !== entityId) || []; + } else { + handler.enabled[entityKey] = Array + .from(new Set([...(handler.enabled[entityKey] || []), entityId])); + } + } else if (handler.enabledByDefault) { + handler.disabled[entityKey] = Array + .from(new Set([...(handler.disabled[entityKey] || []), entityId])); + } else { + handler.enabled[entityKey] = handler.enabled[entityKey].filter((id) => id !== entityId) || []; + } + + handlers[type] = handler; + this.setHandlers(handlers); + } + + updateHandlerOrgs(type, orgId, enabled) { + this.#updatedHandler(type, orgId, enabled, 'orgs'); + } + + updateHandlerSites(type, siteId, enabled) { + this.#updatedHandler(type, siteId, enabled, 'sites'); + } + + enableHandlerForSite(type, site) { + const siteId = site.getId(); + if (this.isHandlerEnabledForSite(type, site)) return; + + this.updateHandlerSites(type, siteId, true); + } + + enableHandlerForOrg(type, org) { + const orgId = org.getId(); + if (this.isHandlerEnabledForOrg(type, org)) return; + + this.updateHandlerOrgs(type, orgId, true); + } + + disableHandlerForSite(type, site) { + const siteId = site.getId(); + if (!this.isHandlerEnabledForSite(type, site)) return; + + this.updateHandlerSites(type, siteId, false); + } + + disableHandlerForOrg(type, org) { + const orgId = org.getId(); + if (!this.isHandlerEnabledForOrg(type, org)) return; + + this.updateHandlerOrgs(type, orgId, false); + } + + async save() { + return this.collection.create(sanitizeIdAndAuditFields(this.constructor.name, this.toJSON())); + } +} + +export default Configuration; diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.schema.js b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.schema.js new file mode 100755 index 00000000..be30795d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.schema.js @@ -0,0 +1,103 @@ +/* + * 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 { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import Joi from 'joi'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Configuration from './configuration.model.js'; +import ConfigurationCollection from './configuration.collection.js'; + +const handlerSchema = Joi.object().pattern(Joi.string(), Joi.object( + { + enabled: Joi.object({ + sites: Joi.array().items(Joi.string()), + orgs: Joi.array().items(Joi.string()), + }), + disabled: Joi.object({ + sites: Joi.array().items(Joi.string()), + orgs: Joi.array().items(Joi.string()), + }), + enabledByDefault: Joi.boolean().required(), + dependencies: Joi.array().items(Joi.object( + { + handler: Joi.string(), + actions: Joi.array().items(Joi.string()), + }, + )), + }, +)).unknown(true); + +const jobsSchema = Joi.array().required(); + +const queueSchema = Joi.object().required(); + +const configurationSchema = Joi.object({ + version: Joi.number().required(), + queues: queueSchema, + handlers: handlerSchema, + jobs: jobsSchema, +}).unknown(true); + +export const checkConfiguration = (data, schema = configurationSchema) => { + const { error, value } = schema.validate(data); + + if (error) { + throw new Error(`Configuration validation error: ${error.message}`); + } + + return value; +}; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(Configuration, ConfigurationCollection) + .addAttribute('handlers', { + type: 'any', + validate: (value) => !value || checkConfiguration(value, handlerSchema), + }) + .addAttribute('jobs', { + type: 'list', + items: { + type: 'map', + properties: { + group: { type: ['audits', 'imports', 'reports'] }, + type: { type: 'string', required: true }, + interval: { type: ['daily', 'weekly'] }, + }, + }, + }) + .addAttribute('queues', { + type: 'any', + required: true, + validate: (value) => isNonEmptyObject(value), + }) + .addAttribute('slackRoles', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAttribute('version', { + type: 'number', + required: true, + readOnly: true, + }) + // eslint-disable-next-line no-template-curly-in-string + .addAllIndexWithTemplateField('version', '${version}'); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/configuration/index.d.ts new file mode 100644 index 00000000..2f902dcc --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/index.d.ts @@ -0,0 +1,111 @@ +/* + * 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, Organization, Site, +} from '../index'; + +export interface Configuration extends BaseModel { + /** + * Retrieves the configuration version. + * @returns {number} The configuration version. + */ + getVersion: () => number; + + /** + * Retrieves the queues configuration. + * @returns {object} The queues configuration. + */ + getQueues: () => object; + + /** + * Retrieves the jobs configuration. + * @returns {Array} The jobs configurations. + */ + getJobs: () => Array; + + /** + * Retrieves the handlers configuration. + * @returns {object} The handlers configuration. + */ + getHandlers: () => object; + + /** + * Retrieves the handler configuration for handler type. + * @param type The handler type. + * @returns {object} The handler type configuration. + */ + getHandler: (type) => object; + + /** + * Retrieves the slack roles configuration. + * @returns {object} The slack roles configuration. + */ + getSlackRoles: () => object; + + /** + * Return true if a handler type is enabled for an organization. + * @param type handler type + * @param org organization + */ + isHandlerEnabledForOrg: (type: string, org: Organization) => boolean; + + /** + * Return true if a handler type is enabled for a site. + * @param type handler type + * @param site site + */ + isHandlerEnabledForSite: (type: string, site: Site) => boolean; + + /** + * Enables a handler type for an site. + * @param type handler type + * @param site site + */ + enableHandlerForSite: (type: string, site: Site) => void; + + /** + * Enables a handler type for an organization. + * @param type handler type + * @param org organization + */ + enableHandlerForOrg: (type: string, org: Organization) => void; + + /** + * Disables a handler type for an site. + * @param type handler type + * @param site site + */ + disableHandlerForSite: (type: string, site: Site) => void; + + /** + * Disables a handler type for an organization. + * @param type handler type + * @param org organization + */ + disableHandlerForOrg: (type:string, org: Organization) => void; +} + +export interface ConfigurationCollection extends BaseCollection { + /** + * Retrieves the latest configuration by version. + * @returns {Configuration} The configuration. + */ + findLatest: () => Configuration; + + /** + * Retrieves the configuration by version. + * @param version The configuration version. + * @returns {Configuration} The configuration. + */ + findByVersion: (version: number) => Configuration; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/index.js b/packages/spacecat-shared-data-access/src/v2/models/configuration/index.js new file mode 100644 index 00000000..c8704d91 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/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 Configuration from './configuration.model.js'; +import ConfigurationCollection from './configuration.collection.js'; + +export { + Configuration, + ConfigurationCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.collection.js b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.collection.js new file mode 100755 index 00000000..2401c56a --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.collection.js @@ -0,0 +1,26 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * ExperimentCollection - A collection class responsible for managing Experiment entities. + * Extends the BaseCollection to provide specific methods for interacting with Experiment records. + * + * @class ExperimentCollection + * @extends BaseCollection + */ +class ExperimentCollection extends BaseCollection { + // add custom methods here +} + +export default ExperimentCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.model.js b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.model.js new file mode 100755 index 00000000..0e9b0e96 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.model.js @@ -0,0 +1,28 @@ +/* + * 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'; + +export const DEFAULT_UPDATED_BY = 'spacecat'; + +/** + * Experiment - A class representing an Experiment entity. + * Provides methods to access and manipulate Experiment-specific data. + * + * @class Experiment + * @extends BaseModel + */ +class Experiment extends BaseModel { + // add your custom methods or overrides here +} + +export default Experiment; diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.schema.js b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.schema.js new file mode 100644 index 00000000..22c219dc --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.schema.js @@ -0,0 +1,70 @@ +/* + * 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 { isIsoDate, isNonEmptyObject, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Experiment, { DEFAULT_UPDATED_BY } from './experiment.model.js'; +import ExperimentCollection from './experiment.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/ + */ + +const schema = new SchemaBuilder(Experiment, ExperimentCollection) + .addReference('belongs_to', 'Site', ['expId', 'url', 'updatedAt']) + .addAttribute('conversionEventName', { + type: 'string', + }) + .addAttribute('conversionEventValue', { type: 'string' }) + .addAttribute('endDate', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('expId', { + type: 'string', + required: true, + }) + .addAttribute('name', { type: 'string' }) + .addAttribute('startDate', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('status', { + type: ['ACTIVE', 'INACTIVE'], + required: true, + }) + .addAttribute('type', { type: 'string' }) + .addAttribute('url', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('updatedBy', { + type: 'string', + required: true, + default: DEFAULT_UPDATED_BY, + }) + .addAttribute('variants', { + type: 'list', + items: { + type: 'any', + validate: (value) => isNonEmptyObject(value), + }, + required: true, + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/experiment/index.d.ts new file mode 100644 index 00000000..b43b3996 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/index.d.ts @@ -0,0 +1,49 @@ +/* + * 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'; + +export interface Experiment extends BaseModel { + getConversionEventName(): string; + getConversionEventValue(): string; + getEndDate(): number; + getExpId(): string; + getName(): string; + getSite(): Promise; + getSiteId(): string; + getStartDate(): number; + getStatus(): string; + getType(): string; + getUrl(): string; + getVariants(): object; + setConversionEventName(conversionEventName: string): Experiment; + setConversionEventValue(conversionEventValue: string): Experiment; + setEndDate(endDate: number): Experiment; + setExpId(expId: string): Experiment; + setName(name: string): Experiment; + setStartDate(startDate: number): Experiment; + setStatus(status: string): Experiment; + setType(type: string): Experiment; + setUrl(url: string): Experiment; + setVariants(variants: object): Experiment; +} + +export interface ExperimentCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteIdAndExpId(siteId: string, expId: string): Promise; + findBySiteIdAndExpId(siteId: string, expId: string): Promise; + findBySiteIdAndExpIdAndUrl( + siteId: string, + expId: string, + url: string, + ): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/index.js b/packages/spacecat-shared-data-access/src/v2/models/experiment/index.js new file mode 100644 index 00000000..4324ba7e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/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 Experiment from './experiment.model.js'; +import ExperimentCollection from './experiment.collection.js'; + +export { + Experiment, + ExperimentCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.collection.js b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.collection.js new file mode 100644 index 00000000..d6069afc --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.collection.js @@ -0,0 +1,45 @@ +/* + * 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 { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import BaseCollection from '../base/base.collection.js'; +import { ValidationError } from '../../errors/index.js'; + +/** + * ImportJobCollection - A collection class responsible for managing ImportJob entities. + * Extends the BaseCollection to provide specific methods for interacting with ImportJob records. + * + * @class ImportJobCollection + * @extends BaseCollection + */ +class ImportJobCollection extends BaseCollection { + async allByDateRange(startDate, endDate) { + if (!isIsoDate(startDate)) { + throw new ValidationError(`Invalid start date: ${startDate}`); + } + + if (!isIsoDate(endDate)) { + throw new ValidationError(`Invalid end date: ${endDate}`); + } + + return this.all({}, { + between: { + attribute: 'startedAt', + start: startDate, + end: endDate, + }, + }); + } +} + +export default ImportJobCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.model.js b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.model.js new file mode 100644 index 00000000..5bf64f4b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.model.js @@ -0,0 +1,55 @@ +/* + * 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'; + +/** + * Supported Import Options. + */ +export const ImportOptions = { + ENABLE_JAVASCRIPT: 'enableJavascript', + PAGE_LOAD_TIMEOUT: 'pageLoadTimeout', +}; + +/** + * Import Job Status types. + * Any changes to this object needs to be reflected in the index.d.ts file as well. + */ +export const ImportJobStatus = { + RUNNING: 'RUNNING', + COMPLETE: 'COMPLETE', + FAILED: 'FAILED', + STOPPED: 'STOPPED', +}; + +/** + * ImportURL Status types. + * Any changes to this object needs to be reflected in the index.d.ts file as well. + */ +export const ImportUrlStatus = { + PENDING: 'PENDING', + REDIRECT: 'REDIRECT', + ...ImportJobStatus, +}; + +/** + * ImportJob - A class representing an ImportJob entity. + * Provides methods to access and manipulate ImportJob-specific data. + * + * @class ImportJob + * @extends BaseModel + */ +class ImportJob extends BaseModel { + // add your custom methods or overrides here +} + +export default ImportJob; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.schema.js b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.schema.js new file mode 100755 index 00000000..055af993 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.schema.js @@ -0,0 +1,152 @@ +/* + * 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 { + isInteger, + isIsoDate, + isNumber, + isObject, + isValidUrl, +} from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import ImportJob, { ImportJobStatus, ImportOptions } from './import-job.model.js'; +import ImportJobCollection from './import-job.collection.js'; + +const ImportOptionTypeValidator = { + [ImportOptions.ENABLE_JAVASCRIPT]: (value) => { + if (value !== true && value !== false) { + throw new Error(`Invalid value for ${ImportOptions.ENABLE_JAVASCRIPT}: ${value}`); + } + }, + [ImportOptions.PAGE_LOAD_TIMEOUT]: (value) => { + if (!isInteger(value) || value < 0) { + throw new Error(`Invalid value for ${ImportOptions.PAGE_LOAD_TIMEOUT}: ${value}`); + } + }, +}; + +const validateOptions = (options) => { + if (!isObject(options)) { + throw new Error(`Invalid options: ${options}`); + } + + const invalidOptions = Object.keys(options).filter( + (key) => !Object.values(ImportOptions) + .some((value) => value.toLowerCase() === key.toLowerCase()), + ); + + if (invalidOptions.length > 0) { + throw new Error(`Invalid options: ${invalidOptions}`); + } + + // validate each option for it's expected data type + Object.keys(options).forEach((key) => { + if (ImportOptionTypeValidator[key]) { + ImportOptionTypeValidator[key](options[key]); + } + }); + + return true; +}; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(ImportJob, ImportJobCollection) + .addReference('has_many', 'ImportUrls') + .addAttribute('baseURL', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('duration', { + type: 'number', + default: 0, + validate: (value) => !value || isNumber(value), + }) + .addAttribute('endedAt', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('failedCount', { + type: 'number', + default: 0, + validate: (value) => !value || isInteger(value), + }) + .addAttribute('hasCustomHeaders', { + type: 'boolean', + default: false, + }) + .addAttribute('hasCustomImportJs', { + type: 'boolean', + default: false, + }) + .addAttribute('hashedApiKey', { + type: 'string', + required: true, + }) + .addAttribute('importQueueId', { + type: 'string', + }) + .addAttribute('initiatedBy', { + type: 'map', + properties: { + apiKeyName: { type: 'string' }, + imsOrgId: { type: 'string' }, + imsUserId: { type: 'string' }, + userAgent: { type: 'string' }, + }, + }) + .addAttribute('options', { + type: 'any', + validate: (value) => !value || validateOptions(value), + }) + .addAttribute('redirectCount', { + type: 'number', + default: 0, + validate: (value) => !value || isInteger(value), + }) + .addAttribute('status', { + type: Object.values(ImportJobStatus), + required: true, + }) + .addAttribute('startedAt', { + type: 'string', + required: true, + readOnly: true, + default: () => new Date().toISOString(), + validate: (value) => isIsoDate(value), + }) + .addAttribute('successCount', { + type: 'number', + default: 0, + validate: (value) => !value || isInteger(value), + }) + .addAttribute('urlCount', { + type: 'number', + default: 0, + validate: (value) => !value || isInteger(value), + }) + .addAllIndexWithComposite('startedAt') + .addIndex( + 'byStatus', + { composite: ['status'] }, + { composite: ['updatedAt'] }, + ); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/import-job/index.d.ts new file mode 100644 index 00000000..3cb36778 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/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 } from '../base'; + +export interface ImportJob extends BaseModel { + getBaseURL(): string, + getDuration(): number, + getEndedAt(): number, + getFailedCount(): number, + getHasCustomHeaders(): boolean, + getHasCustomImportJs(): boolean, + getHashedApiKey(): string, + getImportQueueId(): string, + getInitiatedBy(): string, + getOptions(): string, + getRedirectCount(): number, + getStatus(): string, + getStartedAt(): number, + getSuccessCount(): number, + getUrlCount(): number, + setBaseURL(baseURL: string): void, + setDuration(duration: number): void, + setEndedAt(endTime: number): void, + setFailedCount(failedCount: number): void, + setHasCustomHeaders(hasCustomHeaders: boolean): void, + setHasCustomImportJs(hasCustomImportJs: boolean): void, + setHashedApiKey(hashedApiKey: string): void, + setImportQueueId(importQueueId: string): void, + setInitiatedBy(initiatedBy: string): void, + setOptions(options: string): void, + setRedirectCount(redirectCount: number): void, + setStatus(status: string): void, + setStartedAt(startTime: number): void, + setSuccessCount(successCount: number): void, + setUrlCount(urlCount: number): void, +} + +export interface ImportJobCollection extends BaseCollection { + allByDateRange(startDate: number, endDate: number): Promise; + allByStatus(status: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/index.js b/packages/spacecat-shared-data-access/src/v2/models/import-job/index.js new file mode 100644 index 00000000..7ab8ebc0 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/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 ImportJob from './import-job.model.js'; +import ImportJobCollection from './import-job.collection.js'; + +export { + ImportJob, + ImportJobCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.collection.js b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.collection.js new file mode 100755 index 00000000..d00b8502 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.collection.js @@ -0,0 +1,26 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * ImportUrlCollection - A collection class responsible for managing ImportUrl entities. + * Extends the BaseCollection to provide specific methods for interacting with ImportUrl records. + * + * @class ImportUrlCollection + * @extends BaseCollection + */ +class ImportUrlCollection extends BaseCollection { + // add custom methods here +} + +export default ImportUrlCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.model.js b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.model.js new file mode 100644 index 00000000..0ca9d9e3 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.model.js @@ -0,0 +1,28 @@ +/* + * 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'; + +export const IMPORT_URL_EXPIRES_IN_DAYS = 30; + +/** + * ImportUrl - A class representing an ImportUrl entity. + * Provides methods to access and manipulate ImportUrl-specific data. + * + * @class ImportUrl + * @extends BaseModel + */ +class ImportUrl extends BaseModel { + // add your custom methods or overrides here +} + +export default ImportUrl; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.schema.js b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.schema.js new file mode 100644 index 00000000..e1c9728c --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.schema.js @@ -0,0 +1,59 @@ +/* + * 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 { isIsoDate, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import { ImportUrlStatus } from '../import-job/import-job.model.js'; +import SchemaBuilder from '../base/schema.builder.js'; +import ImportUrl, { IMPORT_URL_EXPIRES_IN_DAYS } from './import-url.model.js'; +import ImportUrlCollection from './import-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/ + */ + +const schema = new SchemaBuilder(ImportUrl, ImportUrlCollection) + .addReference('belongs_to', 'ImportJob', ['status']) + .addAttribute('expiresAt', { + type: 'string', + required: true, + validate: (value) => isIsoDate(value), + default: () => { + const date = new Date(); + date.setDate(date.getDate() + IMPORT_URL_EXPIRES_IN_DAYS); + return date.toISOString(); + }, + }) + .addAttribute('file', { + type: 'string', + }) + .addAttribute('path', { + type: 'string', + }) + .addAttribute('reason', { + type: 'string', + }) + .addAttribute('status', { + type: Object.values(ImportUrlStatus), + required: true, + }) + .addAttribute('url', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/import-url/index.d.ts new file mode 100644 index 00000000..344a4724 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-url/index.d.ts @@ -0,0 +1,35 @@ +/* + * 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 } from '../base'; + +export interface ImportUrl extends BaseModel { + getExpiresAt(): number, + getFile(): string, + getImportJobId(): string, + getPath(): string, + getReason(): string, + getStatus(): string, + getUrl(): string, + setExpiresAt(expiresAt: number): void, + setFile(file: string): void, + setImportJobId(importJobId: string): void, + setPath(path: string): void, + setReason(reason: string): void, + setStatus(status: string): void, + setUrl(url: string): void, +} + +export interface ImportUrlCollection extends BaseCollection { + allByImportJobId(importJobId: string): Promise; + allByImportUrlsByJobIdAndStatus(importJobId: string, status: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/index.js b/packages/spacecat-shared-data-access/src/v2/models/import-url/index.js new file mode 100644 index 00000000..fa44c022 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-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 ImportUrl from './import-url.model.js'; +import ImportUrlCollection from './import-url.collection.js'; + +export { + ImportUrl, + ImportUrlCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/index.d.ts index c49c6c8b..06580776 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/index.d.ts +++ b/packages/spacecat-shared-data-access/src/v2/models/index.d.ts @@ -10,102 +10,14 @@ * governing permissions and limitations under the License. */ -import type { ValidationError } from '../index.d.ts'; - -/** - * Interface representing a base model for interacting with a data entity. - */ -export interface BaseModel { - getId(): string; - getCreatedAt(): string; - getUpdatedAt(): string; - remove(): Promise; - save(): Promise; -} - -export interface MultiStatusCreateResult { - createdItems: T[], - errorItems: { item: object, error: ValidationError }[], -} - -/** - * Interface representing an Opportunity model, extending BaseModel. - */ -export interface Opportunity extends BaseModel { /* eslint-disable no-use-before-define */ - addSuggestions(suggestions: object[]): Promise>; - // eslint-disable-next-line no-use-before-define - getSuggestions(): Promise; - getSiteId(): string; - setSiteId(siteId: string): Opportunity; - getAuditId(): string; - setAuditId(auditId: string): Opportunity; - getRunbook(): string; - setRunbook(runbook: string): Opportunity; - getGuidance(): string; - setGuidance(guidance: string): Opportunity; - getTitle(): string; - setTitle(title: string): Opportunity; - getDescription(): string; - setDescription(description: string): Opportunity; - getType(): string; - getStatus(): string; - setStatus(status: string): Opportunity; - getOrigin(): string; - setOrigin(origin: string): Opportunity; - getTags(): string[]; - setTags(tags: string[]): Opportunity; - getData(): object; - setData(data: object): Opportunity; -} - -/** - * Interface representing a Suggestion model, extending BaseModel. - */ -export interface Suggestion extends BaseModel { - getOpportunity(): Promise; - getOpportunityId(): string; - setOpportunityId(opportunityId: string): Suggestion; - getType(): string; - getStatus(): string; - setStatus(status: string): Suggestion; - getRank(): number; - setRank(rank: number): Suggestion; - getData(): object; - setData(data: object): Suggestion; - getKpiDeltas(): object; - setKpiDeltas(kpiDeltas: object): Suggestion; -} - -/** - * Interface representing a base collection for interacting with data entities. - */ -export interface BaseCollection { - findById(id: string): Promise; - findByIndexKeys(indexKeys: object): Promise; - create(item: object): Promise; - createMany(items: object[]): Promise>; -} - -/** - * Interface representing the Opportunity collection, extending BaseCollection. - */ -export interface OpportunityCollection extends BaseCollection { - allBySiteId(siteId: string): Promise; - allBySiteIdAndStatus(siteId: string, status: string): Promise; -} - -/** - * Interface representing the Suggestion collection, extending BaseCollection. - */ -export interface SuggestionCollection extends BaseCollection { - allByOpportunityId(opportunityId: string): Promise; - allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; - bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise; -} - -/** - * Interface representing the Model Factory for creating and managing model collections. - */ -export interface ModelFactory { - getCollection(collectionName: string): BaseCollection; -} +export type * from './audit/index.d.ts'; +export type * from './configuration/index.d.ts'; +export type * from './base/index.d.ts'; +export type * from './experiment/index.d.ts'; +export type * from './key-event/index.d.ts'; +export type * from './opportunity/index.d.ts'; +export type * from './organization/index.d.ts'; +export type * from './site/index.d.ts'; +export type * from './site-candidate/index.d.ts'; +export type * from './site-top-page/index.d.ts'; +export type * from './suggestion/index.d.ts'; diff --git a/packages/spacecat-shared-data-access/src/v2/models/index.js b/packages/spacecat-shared-data-access/src/v2/models/index.js old mode 100644 new mode 100755 index d3948b33..839b609e --- a/packages/spacecat-shared-data-access/src/v2/models/index.js +++ b/packages/spacecat-shared-data-access/src/v2/models/index.js @@ -10,18 +10,17 @@ * governing permissions and limitations under the License. */ -import ModelFactory from './model.factory.js'; -import BaseModel from './base.model.js'; -import Opportunity from './opportunity.model.js'; -import OpportunityCollection from './opportunity.collection.js'; -import Suggestion from './suggestion.model.js'; -import SuggestionCollection from './suggestion.collection.js'; - -export { - ModelFactory, - BaseModel, - Opportunity, - OpportunityCollection, - Suggestion, - SuggestionCollection, -}; +export * from './api-key/index.js'; +export * from './audit/index.js'; +export * from './base/index.js'; +export * from './configuration/index.js'; +export * from './experiment/index.js'; +export * from './import-job/index.js'; +export * from './import-url/index.js'; +export * from './key-event/index.js'; +export * from './opportunity/index.js'; +export * from './organization/index.js'; +export * from './site-candidate/index.js'; +export * from './site-top-page/index.js'; +export * from './site/index.js'; +export * from './suggestion/index.js'; diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/key-event/index.d.ts new file mode 100644 index 00000000..d8b02285 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/index.d.ts @@ -0,0 +1,28 @@ +/* + * 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 } from '../index'; + +export interface KeyEvent extends BaseModel { + getName(): string; + getSiteId(): string; + getTime(): number; + getType(): string; + setName(name: string): KeyEvent; + setSiteId(siteId: string): KeyEvent; + setTime(time: number): KeyEvent; + setType(type: string): KeyEvent; +} + +export interface KeyEventCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/index.js b/packages/spacecat-shared-data-access/src/v2/models/key-event/index.js new file mode 100644 index 00000000..5fb38ce4 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/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 KeyEvent from './key-event.model.js'; +import KeyEventCollection from './key-event.collection.js'; + +export { + KeyEvent, + KeyEventCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.collection.js b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.collection.js new file mode 100644 index 00000000..54a3859b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.collection.js @@ -0,0 +1,26 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * KeyEventCollection - A collection class responsible for managing KeyEvent entities. + * Extends the BaseCollection to provide specific methods for interacting with KeyEvent records. + * + * @class KeyEventCollection + * @extends BaseCollection + */ +class KeyEventCollection extends BaseCollection { + // add custom methods here +} + +export default KeyEventCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.model.js b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.model.js new file mode 100755 index 00000000..08ec1af1 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.model.js @@ -0,0 +1,37 @@ +/* + * 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'; + +export const KEY_EVENT_TYPES = { + PERFORMANCE: 'PERFORMANCE', + SEO: 'SEO', + CONTENT: 'CONTENT', + CODE: 'CODE', + THIRD_PARTY: 'THIRD PARTY', + EXPERIMENTATION: 'EXPERIMENTATION', + NETWORK: 'NETWORK', + STATUS_CHANGE: 'STATUS CHANGE', +}; + +/** + * KeyEvent - A class representing an KeyEvent entity. + * Provides methods to access and manipulate KeyEvent-specific data. + * + * @class KeyEvent + * @extends BaseModel + */ +class KeyEvent extends BaseModel { + // add your custom methods or overrides here +} + +export default KeyEvent; diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.schema.js b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.schema.js new file mode 100644 index 00000000..d28afc74 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.schema.js @@ -0,0 +1,45 @@ +/* + * 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 { hasText, isIsoDate } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import { KEY_EVENT_TYPES } from '../../../models/key-event.js'; +import KeyEvent from './key-event.model.js'; +import KeyEventCollection from './key-event.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/ + */ + +const schema = new SchemaBuilder(KeyEvent, KeyEventCollection) + .addReference('belongs_to', 'Site', ['time']) + .addAttribute('name', { + type: 'string', + required: true, + validate: (value) => hasText(value), + }) + .addAttribute('type', { + type: Object.values(KEY_EVENT_TYPES), + required: true, + }) + .addAttribute('time', { + type: 'string', + required: true, + validate: (value) => isIsoDate(value), + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/model.factory.js b/packages/spacecat-shared-data-access/src/v2/models/model.factory.js deleted file mode 100644 index d6c3b5ac..00000000 --- a/packages/spacecat-shared-data-access/src/v2/models/model.factory.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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 OpportunityCollection from './opportunity.collection.js'; -import SuggestionCollection from './suggestion.collection.js'; - -/** - * ModelFactory - A factory class responsible for creating and managing collections - * of different models. This class serves as a centralized point for accessing and - * instantiating model collections. - * - * @class ModelFactory - */ -class ModelFactory { - /** - * Constructs an instance of ModelFactory. - * @constructor - * @param {Object} service - The ElectroDB service instance used to manage entities. - * @param {Object} logger - A logger for capturing and logging information. - */ - constructor(service, logger) { - this.service = service; - this.logger = logger; - this.models = new Map(); - - this.initialize(); - } - - /** - * Initializes the collections managed by the ModelFactory. - * This method creates instances of each collection and stores them in an internal map. - * @private - */ - initialize() { - const opportunityCollection = new OpportunityCollection( - this.service, - this, - this.logger, - ); - const suggestionCollection = new SuggestionCollection( - this.service, - this, - this.logger, - ); - - this.models.set(OpportunityCollection.name, opportunityCollection); - this.models.set(SuggestionCollection.name, suggestionCollection); - } - - /** - * Gets a collection instance by its name. - * @param {string} collectionName - The name of the collection to retrieve. - * @returns {Object} - The requested collection instance. - * @throws {Error} - Throws an error if the collection with the specified name is not found. - */ - getCollection(collectionName) { - const collection = this.models.get(collectionName); - if (!collection) { - throw new Error(`Collection ${collectionName} not found`); - } - return collection; - } -} - -export default ModelFactory; diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity.collection.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity.collection.js deleted file mode 100644 index 03b6012c..00000000 --- a/packages/spacecat-shared-data-access/src/v2/models/opportunity.collection.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.collection.js'; -import Opportunity from './opportunity.model.js'; - -/** - * OpportunityCollection - A collection class responsible for managing Opportunity entities. - * Extends the BaseCollection to provide specific methods for interacting with Opportunity records. - * - * @class OpportunityCollection - * @extends BaseCollection - */ -class OpportunityCollection extends BaseCollection { - /** - * Constructs an instance of OpportunityCollection. Tells the base class which model to use. - * @constructor - * @param {Object} service - The ElectroDB service instance used to manage Opportunity entities. - * @param {Object} modelFactory - A factory for creating model instances. - * @param {Object} log - A logger for capturing logging information. - */ - constructor(service, modelFactory, log) { - super(service, modelFactory, Opportunity, log); - } - - /** - * Retrieves all Opportunity entities by their associated site ID. - * @async - * @param {string} siteId - The unique identifier of the site. - * @returns {Promise>} - A promise that resolves to an array of - * Opportunity instances related to the given site ID. - * @throws {Error} - Throws an error if the siteId is not provided or if the query fails. - */ - async allBySiteId(siteId) { - if (!hasText(siteId)) { - throw new Error('SiteId is required'); - } - return this.findByIndexKeys({ siteId }); - } - - /** - * Retrieves all Opportunity entities by their associated site ID and status. - * @param {string} siteId - The unique identifier of the site. - * @param {string} status - The status of the Opportunity entities to retrieve. - * @return {Promise>} - A promise that resolves to an array of - * Opportunity instances. - * @throws {Error} - Throws an error if the siteId or status is not provided or if the - * query fails. - */ - async allBySiteIdAndStatus(siteId, status) { - if (!hasText(siteId)) { - throw new Error('SiteId is required'); - } - - if (!hasText(status)) { - throw new Error('Status is required'); - } - - return this.findByIndexKeys({ siteId, status }); - } -} - -export default OpportunityCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.d.ts new file mode 100644 index 00000000..cc7afa6e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.d.ts @@ -0,0 +1,46 @@ +/* + * 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, MultiStatusCreateResult, Suggestion, +} from '../index'; + +export interface Opportunity extends BaseModel { + addSuggestions(suggestions: object[]): Promise>; + getAuditId(): string; + getData(): object; + getDescription(): string; + getGuidance(): string; + getOrigin(): string; + getRunbook(): string; + getSiteId(): string; + getStatus(): string; + getSuggestions(): Promise; + getTags(): string[]; + getTitle(): string; + getType(): string; + setAuditId(auditId: string): Opportunity; + setData(data: object): Opportunity; + setDescription(description: string): Opportunity; + setGuidance(guidance: string): Opportunity; + setOrigin(origin: string): Opportunity; + setRunbook(runbook: string): Opportunity; + setSiteId(siteId: string): Opportunity; + setStatus(status: string): Opportunity; + setTags(tags: string[]): Opportunity; + setTitle(title: string): Opportunity; +} + +export interface OpportunityCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteIdAndStatus(siteId: string, status: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.js new file mode 100644 index 00000000..ffce3d09 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/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 Opportunity from './opportunity.model.js'; +import OpportunityCollection from './opportunity.collection.js'; + +export { + Opportunity, + OpportunityCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.collection.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.collection.js new file mode 100644 index 00000000..28619e21 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.collection.js @@ -0,0 +1,26 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * OpportunityCollection - A collection class responsible for managing Opportunity entities. + * Extends the BaseCollection to provide specific methods for interacting with Opportunity records. + * + * @class OpportunityCollection + * @extends BaseCollection + */ +class OpportunityCollection extends BaseCollection { + // add custom methods here +} + +export default OpportunityCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity.model.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.model.js similarity index 85% rename from packages/spacecat-shared-data-access/src/v2/models/opportunity.model.js rename to packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.model.js index 0bc713a9..47a5b9d5 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/opportunity.model.js +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.model.js @@ -10,7 +10,20 @@ * governing permissions and limitations under the License. */ -import BaseModel from './base.model.js'; +import BaseModel from '../base/base.model.js'; + +export const ORIGINS = { + ESS_OPS: 'ESS_OPS', + AI: 'AI', + AUTOMATION: 'AUTOMATION', +}; + +export const STATUSES = { + NEW: 'NEW', + IN_PROGRESS: 'IN_PROGRESS', + IGNORED: 'IGNORED', + RESOLVED: 'RESOLVED', +}; /** * Opportunity - A class representing an Opportunity entity. @@ -38,7 +51,7 @@ class Opportunity extends BaseModel { ...suggestion, [this.idName]: this.getId(), })); - return this.modelFactory + return this.entityRegistry .getCollection('SuggestionCollection') .createMany(childSuggestions, this); } diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.schema.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.schema.js new file mode 100644 index 00000000..53d29516 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.schema.js @@ -0,0 +1,69 @@ +/* + * 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 { isNonEmptyObject, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Opportunity, { ORIGINS, STATUSES } from './opportunity.model.js'; +import OpportunityCollection from './opportunity.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/ + */ + +const schema = new SchemaBuilder(Opportunity, OpportunityCollection) + .addReference('belongs_to', 'Site', ['status', 'updatedAt']) + .addReference('belongs_to', 'Audit', ['updatedAt'], { required: false }) + .addReference('has_many', 'Suggestions', ['updatedAt'], { removeDependents: true }) + .addAttribute('runbook', { + type: 'string', + validate: (value) => !value || isValidUrl(value), + }) + .addAttribute('type', { + type: 'string', + readOnly: true, + required: true, + }) + .addAttribute('data', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAttribute('origin', { + type: Object.values(ORIGINS), + required: true, + }) + .addAttribute('title', { + type: 'string', + required: true, + }) + .addAttribute('description', { + type: 'string', + }) + .addAttribute('status', { + type: Object.values(STATUSES), + required: true, + default: 'NEW', + }) + .addAttribute('guidance', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAttribute('tags', { + type: 'set', + items: 'string', + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/organization/index.d.ts new file mode 100644 index 00000000..4e71f721 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/index.d.ts @@ -0,0 +1,28 @@ +/* + * 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 } from '../index'; + +export interface Organization extends BaseModel { + getConfig(): object; + getFulfillableItems(): object; + getImsOrgId(): string; + getName(): string; + setConfig(config: object): Organization; + setFulfillableItems(fulfillableItems: object): Organization; + setImsOrgId(imsOrgId: string): Organization; + setName(name: string): Organization; +} + +export interface OrganizationCollection extends BaseCollection { + allByImsOrgId(imsOrgId: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/index.js b/packages/spacecat-shared-data-access/src/v2/models/organization/index.js new file mode 100644 index 00000000..fcc09f6d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/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 Organization from './organization.model.js'; +import OrganizationCollection from './organization.collection.js'; + +export { + Organization, + OrganizationCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/organization.collection.js b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.collection.js new file mode 100644 index 00000000..4e7cae32 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.collection.js @@ -0,0 +1,26 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * OrganizationCollection - A collection class responsible for managing Organization entities. + * Extends the BaseCollection to provide specific methods for interacting with Organization records. + * + * @class OrganizationCollection + * @extends BaseCollection + */ +class OrganizationCollection extends BaseCollection { + // add custom methods here +} + +export default OrganizationCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/organization.model.js b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.model.js new file mode 100755 index 00000000..71c87fd0 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.model.js @@ -0,0 +1,31 @@ +/* + * 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 { Config } from '../../../models/site/config.js'; +import BaseModel from '../base/base.model.js'; + +/** + * Organization - A class representing an Organization entity. + * Provides methods to access and manipulate Organization-specific data. + * + * @class Organization + * @extends BaseModel + */ +class Organization extends BaseModel { + // add your custom methods or overrides here + + getConfig() { + return Config(this.record.config); + } +} + +export default Organization; diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/organization.schema.js b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.schema.js new file mode 100644 index 00000000..36b120df --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.schema.js @@ -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. + */ + +/* c8 ignore start */ + +import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import { DEFAULT_CONFIG, validateConfiguration } from '../../../models/site/config.js'; +import SchemaBuilder from '../base/schema.builder.js'; +import Organization from './organization.model.js'; +import OrganizationCollection from './organization.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/ + */ + +const schema = new SchemaBuilder(Organization, OrganizationCollection) + // this will add an attribute 'organizationId' as well as an index 'byOrganizationId' + .addReference('has_many', 'Sites') + .addAttribute('config', { + type: 'any', + required: true, + default: DEFAULT_CONFIG, + validate: (value) => isNonEmptyObject(validateConfiguration(value)), + }) + .addAttribute('name', { + type: 'string', + required: true, + }) + .addAttribute('imsOrgId', { + type: 'string', + default: 'default', + }) + .addAttribute('fulfillableItems', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAllIndexWithComposite('imsOrgId'); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.d.ts new file mode 100644 index 00000000..330f3352 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.d.ts @@ -0,0 +1,38 @@ +/* + * 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 } from '../index'; + +export interface SiteCandidate extends BaseModel { + getBaseURL(): string; + getHlxConfig(): object; + getSite(): object; + getSiteId(): string; + getSource(): string; + getStatus(): string; + getUpdatedBy(): string; + setBaseURL(baseURL: string): SiteCandidate; + setHlxConfig(hlxConfig: object): SiteCandidate; + setSiteId(siteId: string): SiteCandidate; + setSource(source: string): SiteCandidate; + setStatus(status: string): SiteCandidate; + setUpdatedBy(updatedBy: string): SiteCandidate; +} + +export interface SiteCandidateCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteIdAndSiteCandidateIdAndUrl( + siteId: string, + siteCandidateId: string, + url: string, + ): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.js b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.js new file mode 100644 index 00000000..b030ceee --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/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 SiteCandidate from './site-candidate.model.js'; +import SiteCandidateCollection from './site-candidate.collection.js'; + +export { + SiteCandidate, + SiteCandidateCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.collection.js b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.collection.js new file mode 100755 index 00000000..f1a08ae2 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.collection.js @@ -0,0 +1,27 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * SiteCandidateCollection - A collection class responsible for managing SiteCandidate entities. + * Extends the BaseCollection to provide specific methods for interacting with + * SiteCandidate records. + * + * @class SiteCandidateCollection + * @extends BaseCollection + */ +class SiteCandidateCollection extends BaseCollection { + // add custom methods here +} + +export default SiteCandidateCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.model.js b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.model.js new file mode 100755 index 00000000..9a18a3c9 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.model.js @@ -0,0 +1,41 @@ +/* + * 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'; + +export const DEFAULT_UPDATED_BY = 'spacecat'; + +export const SITE_CANDIDATE_SOURCES = { + SPACECAT_SLACK_BOT: 'SPACECAT_SLACK_BOT', + RUM: 'RUM', + CDN: 'CDN', +}; + +export const SITE_CANDIDATE_STATUS = { + PENDING: 'PENDING', // site candidate notification sent and waiting for human input + IGNORED: 'IGNORED', // site candidate discarded: not to be added to star catalogue + APPROVED: 'APPROVED', // site candidate is added to star catalogue + ERROR: 'ERROR', // site candidate is discovered +}; + +/** + * SiteCandidate - A class representing an SiteCandidate entity. + * Provides methods to access and manipulate SiteCandidate-specific data. + * + * @class SiteCandidate + * @extends BaseModel + */ +class SiteCandidate extends BaseModel { + // add your custom methods or overrides here +} + +export default SiteCandidate; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.schema.js b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.schema.js new file mode 100755 index 00000000..5557f16a --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.schema.js @@ -0,0 +1,59 @@ +/* + * 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 { isObject, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import { validate as uuidValidate } from 'uuid'; + +import SchemaBuilder from '../base/schema.builder.js'; +import SiteCandidate, { SITE_CANDIDATE_SOURCES, SITE_CANDIDATE_STATUS } from './site-candidate.model.js'; +import SiteCandidateCollection from './site-candidate.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/ + */ + +const schema = new SchemaBuilder(SiteCandidate, SiteCandidateCollection) + .addReference('belongs_to', 'Site') + .addAttribute('siteId', { + type: 'string', + validate: (value) => !value || uuidValidate(value), + }) + .addAttribute('baseURL', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('hlxConfig', { + type: 'any', + required: true, + default: {}, + validate: (value) => isObject(value), + }) + .addAttribute('source', { + type: Object.values(SITE_CANDIDATE_SOURCES), + required: true, + }) + .addAttribute('status', { + type: Object.values(SITE_CANDIDATE_STATUS), + required: true, + }) + .addAttribute('updatedBy', { + type: 'string', + }) + .addAllIndexWithComposite('baseURL'); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.d.ts new file mode 100644 index 00000000..59ba8b45 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.d.ts @@ -0,0 +1,35 @@ +/* + * 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 } from '../index'; + +export interface SiteTopPage extends BaseModel { + getGeo(): string; + getImportedAt(): number; + getSiteId(): string; + getSource(): string; + getTopKeyword(): string; + getTraffic(): number; + getUrl(): string; + setGeo(geo: string): SiteTopPage; + setImportedAt(importedAt: number): SiteTopPage; + setSiteId(siteId: string): SiteTopPage; + setSource(source: string): SiteTopPage; + setTopKeyword(topKeyword: string): SiteTopPage; + setTraffic(traffic: number): SiteTopPage; + setUrl(url: string): SiteTopPage; +} + +export interface SiteTopPageCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteIdAndSourceAndGeo(siteId: string, source: string, geo: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.js b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.js new file mode 100644 index 00000000..895684b0 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/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 SiteTopPage from './site-top-page.model.js'; +import SiteTopPageCollection from './site-top-page.collection.js'; + +export { + SiteTopPage, + SiteTopPageCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.collection.js b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.collection.js new file mode 100644 index 00000000..e4cc5baf --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.collection.js @@ -0,0 +1,44 @@ +/* + * 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'; + +/** + * SiteTopPageCollection - A collection class responsible for managing SiteTopPage entities. + * Extends the BaseCollection to provide specific methods for interacting with SiteTopPage records. + * + * @class SiteTopPageCollection + * @extends BaseCollection + */ +class SiteTopPageCollection extends BaseCollection { + async removeForSiteId(siteId, source, geo) { + if (!hasText(siteId)) { + throw new Error('SiteId is required'); + } + + let topPagesToRemove; + + if (hasText(source) && hasText(geo)) { + topPagesToRemove = await this.allBySiteIdAndSourceAndGeo(siteId, source, geo); + } else { + topPagesToRemove = await this.allBySiteId(siteId); + } + + const topPageIdsToRemove = topPagesToRemove.map((topPage) => topPage.getId()); + + await this.removeByIds(topPageIdsToRemove); + } +} + +export default SiteTopPageCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.model.js b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.model.js new file mode 100755 index 00000000..c33d044d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.model.js @@ -0,0 +1,28 @@ +/* + * 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'; + +export const DEFAULT_GEO = 'global'; + +/** + * SiteTopPage - A class representing an SiteTopPage entity. + * Provides methods to access and manipulate SiteTopPage-specific data. + * + * @class SiteTopPage + * @extends BaseModel + */ +class SiteTopPage extends BaseModel { + // add your custom methods or overrides here +} + +export default SiteTopPage; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.schema.js b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.schema.js new file mode 100644 index 00000000..d73a6032 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.schema.js @@ -0,0 +1,65 @@ +/* + * 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 { isInteger, isIsoDate, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import { validate as uuidValidate } from 'uuid'; + +import SchemaBuilder from '../base/schema.builder.js'; +import SiteTopPage, { DEFAULT_GEO } from './site-top-page.model.js'; +import SiteTopPageCollection from './site-top-page.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/ + */ + +const schema = new SchemaBuilder(SiteTopPage, SiteTopPageCollection) + .addReference('belongs_to', 'Site', ['source', 'geo', 'traffic']) + .addAttribute('siteId', { + type: 'string', + required: true, + validate: (value) => uuidValidate(value), + }) + .addAttribute('url', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('traffic', { + type: 'number', + required: true, + validate: (value) => isInteger(value), + }) + .addAttribute('source', { + type: 'string', + required: true, + }) + .addAttribute('topKeyword', { + type: 'string', + }) + .addAttribute('geo', { + type: 'string', + required: false, + default: DEFAULT_GEO, + }) + .addAttribute('importedAt', { + type: 'string', + required: true, + default: () => new Date().toISOString(), + validate: (value) => isIsoDate(value), + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/site/index.d.ts new file mode 100644 index 00000000..1bb0ae2b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/index.d.ts @@ -0,0 +1,43 @@ +/* + * 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 { + Audit, BaseCollection, BaseModel, Organization, +} from '../index'; + +export interface Site extends BaseModel { + getAudits(): Promise; + getBaseURL(): string; + getConfig(): object; + getDeliveryType(): string; + getFulfillableItems(): object; + getGitHubURL(): string; + getHlxConfig(): object; + getIsLive(): boolean; + getIsLiveToggledAt(): string; + getOrganization(): Promise; + getOrganizationId(): string; + setConfig(config: object): Site; + setDeliveryType(deliveryType: string): Site; + setFulfillableItems(fulfillableItems: object): Site; + setGitHubURL(gitHubURL: string): Site; + setHlxConfig(hlxConfig: object): Site; + setIsLive(isLive: boolean): Site; + setOrganizationId(organizationId: string): Site; + toggleLive(): Site; +} + +export interface SiteCollection extends BaseCollection { + findByBaseURL(siteId: string): Promise; + allByDeliveryType(siteId: string): Promise; + allByOrganizationId(siteId: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/index.js b/packages/spacecat-shared-data-access/src/v2/models/site/index.js new file mode 100644 index 00000000..d0966dbd --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/index.js @@ -0,0 +1,20 @@ +/* + * 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-disable-next-line import/no-cycle +import Site from './site.model.js'; +import SiteCollection from './site.collection.js'; + +export { + Site, + SiteCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/site.collection.js b/packages/spacecat-shared-data-access/src/v2/models/site/site.collection.js new file mode 100755 index 00000000..6169fa0a --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/site.collection.js @@ -0,0 +1,28 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * SiteCollection - A collection class responsible for managing Site entities. + * Extends the BaseCollection to provide specific methods for interacting with Site records. + * + * @class SiteCollection + * @extends BaseCollection + */ +class SiteCollection extends BaseCollection { + async allSitesToAudit() { + return (await this.all({ attributes: ['siteId'] })).map((site) => site.getId()); + } +} + +export default SiteCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/site.model.js b/packages/spacecat-shared-data-access/src/v2/models/site/site.model.js new file mode 100644 index 00000000..e600d47e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/site.model.js @@ -0,0 +1,47 @@ +/* + * 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 { Config } from '../../../models/site/config.js'; +import BaseModel from '../base/base.model.js'; + +export const DELIVERY_TYPES = { + AEM_CS: 'aem_cs', + AEM_EDGE: 'aem_edge', + OTHER: 'other', +}; + +export const DEFAULT_DELIVERY_TYPE = DELIVERY_TYPES.AEM_EDGE; + +/** + * A class representing a Site entity. Provides methods to access and manipulate Site-specific data. + * @class Site + * @extends BaseModel + */ +class Site extends BaseModel { + getConfig() { + return Config(this.record.config); + } + + async getLatestAuditByType(auditType) { + const collection = this.entityRegistry.getCollection('AuditCollection'); + + return collection.findByIndexKeys({ siteId: this.getId(), auditType }); + } + + async toggleLive() { + const newIsLive = !this.getIsLive(); + this.setIsLive(newIsLive); + return this; + } +} + +export default Site; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/site.schema.js b/packages/spacecat-shared-data-access/src/v2/models/site/site.schema.js new file mode 100755 index 00000000..e757db73 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/site.schema.js @@ -0,0 +1,91 @@ +/* + * 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 { + isIsoDate, + isNonEmptyObject, + isObject, + isValidUrl, +} from '@adobe/spacecat-shared-utils'; + +import { Config, DEFAULT_CONFIG, validateConfiguration } from '../../../models/site/config.js'; +import SchemaBuilder from '../base/schema.builder.js'; + +import Site, { + DEFAULT_DELIVERY_TYPE, + DELIVERY_TYPES, +} from './site.model.js'; +import SiteCollection from './site.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/ + */ + +const schema = new SchemaBuilder(Site, SiteCollection) + // this will add an attribute 'organizationId' as well as an index 'byOrganizationId' + .addReference('belongs_to', 'Organization') + // has_many references do not add attributes or indexes + .addReference('has_many', 'Audits') + .addReference('has_many', 'Experiments') + .addReference('has_many', 'KeyEvents') + .addReference('has_many', 'Opportunities') + .addReference('has_many', 'SiteCandidates') + .addReference('has_many', 'SiteTopPages') + .addAttribute('baseURL', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('config', { + type: 'any', + required: true, + default: DEFAULT_CONFIG, + validate: (value) => isNonEmptyObject(validateConfiguration(value)), + get: (value) => Config(value), + }) + .addAttribute('deliveryType', { + type: Object.values(DELIVERY_TYPES), + default: DEFAULT_DELIVERY_TYPE, + required: true, + }) + .addAttribute('gitHubURL', { + type: 'string', + validate: (value) => !value || isValidUrl(value), + }) + .addAttribute('hlxConfig', { + type: 'any', + default: {}, + validate: (value) => isObject(value), + }) + .addAttribute('isLive', { + type: 'boolean', + required: true, + default: false, + }) + .addAttribute('isLiveToggledAt', { + type: 'string', + watch: ['isLive'], + set: () => new Date().toISOString(), + validate: (value) => !value || isIsoDate(value), + }) + .addAllIndexWithComposite('baseURL') + .addIndex( + 'byDeliveryType', + { composite: ['deliveryType'] }, + { composite: ['updatedAt'] }, + ); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js deleted file mode 100644 index c667ae03..00000000 --- a/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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.collection.js'; -import Suggestion from './suggestion.model.js'; - -/** - * SuggestionCollection - A collection class responsible for managing Suggestion entities. - * Extends the BaseCollection to provide specific methods for interacting with Suggestion records. - * - * @class SuggestionCollection - * @extends BaseCollection - */ -class SuggestionCollection extends BaseCollection { - /** - * Constructs an instance of SuggestionCollection. Tells the base class which model to use. - * @constructor - * @param {Object} service - The ElectroDB service instance used to manage Suggestion entities. - * @param {Object} modelFactory - A factory for creating model instances. - * @param {Object} log - A logger for capturing logging information. - */ - constructor(service, modelFactory, log) { - super(service, modelFactory, Suggestion, log); - } - - /** - * Retrieves all Suggestion entities by their associated Opportunity ID. - * @async - * @param {string} opportunityId - The unique identifier of the associated Opportunity. - * @returns {Promise} - A promise that resolves to an array of Suggestion - * instances related to the given Opportunity ID. - * @throws {Error} - Throws an error if the opportunityId is not provided or if the query fails. - */ - async allByOpportunityId(opportunityId) { - if (!hasText(opportunityId)) { - throw new Error('OpportunityId is required'); - } - return this.findByIndexKeys({ opportunityId }); - } - - /** - * Retrieves all Suggestion entities by their associated Opportunity ID and status. - * @param {string} opportunityId - The unique identifier of the associated Opportunity. - * @param {string} status - The status of the Suggestion entities - * @return {Promise} - A promise that resolves to an array of - * Suggestion instances. - * @throws {Error} - Throws an error if the opportunityId or status is not provided. - */ - async allByOpportunityIdAndStatus(opportunityId, status) { - if (!hasText(opportunityId)) { - throw new Error('OpportunityId is required'); - } - - if (!hasText(status)) { - throw new Error('Status is required'); - } - - return this.findByIndexKeys({ opportunityId, status }); - } - - /** - * Updates the status of multiple given suggestions. The given status must conform - * to the status enum defined in the Suggestion schema. - * Saves the updated suggestions to the database automatically. - * You don't need to call save() on the suggestions after calling this method. - * @async - * @param {Suggestion[]} suggestions - An array of Suggestion instances to update. - * @param {string} status - The new status to set for the suggestions. - * @return {Promise<*>} - A promise that resolves to the updated suggestions. - * @throws {Error} - Throws an error if the suggestions are not provided - * or if the status is invalid. - */ - async bulkUpdateStatus(suggestions, status) { - if (!Array.isArray(suggestions)) { - throw new Error('Suggestions must be an array'); - } - - const validStatuses = this._getEnumValues('status'); - if (!validStatuses?.includes(status)) { - throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`); - } - - suggestions.forEach((suggestion) => { - suggestion.setStatus(status); - }); - - await this._saveMany(suggestions); - - return suggestions; - } -} - -export default SuggestionCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.d.ts new file mode 100644 index 00000000..63dea9f5 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.d.ts @@ -0,0 +1,34 @@ +/* + * 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, Opportunity } from '../index'; + +export interface Suggestion extends BaseModel { + getData(): object; + getKpiDeltas(): object; + getOpportunity(): Promise; + getOpportunityId(): string; + getRank(): number; + getStatus(): string; + getType(): string; + setData(data: object): Suggestion; + setKpiDeltas(kpiDeltas: object): Suggestion; + setOpportunityId(opportunityId: string): Suggestion; + setRank(rank: number): Suggestion; + setStatus(status: string): Suggestion; +} + +export interface SuggestionCollection extends BaseCollection { + allByOpportunityId(opportunityId: string): Promise; + allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; + bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.js new file mode 100644 index 00000000..1cf61afc --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/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 Suggestion from './suggestion.model.js'; +import SuggestionCollection from './suggestion.collection.js'; + +export { + Suggestion, + SuggestionCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.collection.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.collection.js new file mode 100644 index 00000000..6b29d00e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.collection.js @@ -0,0 +1,55 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; +import { STATUSES } from './suggestion.model.js'; + +/** + * SuggestionCollection - A collection class responsible for managing Suggestion entities. + * Extends the BaseCollection to provide specific methods for interacting with Suggestion records. + * + * @class SuggestionCollection + * @extends BaseCollection + */ +class SuggestionCollection extends BaseCollection { + /** + * Updates the status of multiple given suggestions. The given status must conform + * to the status enum defined in the Suggestion schema. + * Saves the updated suggestions to the database automatically. + * You don't need to call save() on the suggestions after calling this method. + * @async + * @param {Suggestion[]} suggestions - An array of Suggestion instances to update. + * @param {string} status - The new status to set for the suggestions. + * @return {Promise<*>} - A promise that resolves to the updated suggestions. + * @throws {Error} - Throws an error if the suggestions are not provided + * or if the status is invalid. + */ + async bulkUpdateStatus(suggestions, status) { + if (!Array.isArray(suggestions)) { + throw new Error('Suggestions must be an array'); + } + + if (!Object.values(STATUSES).includes(status)) { + throw new Error(`Invalid status: ${status}. Must be one of: ${Object.values(STATUSES).join(', ')}`); + } + + suggestions.forEach((suggestion) => { + suggestion.setStatus(status); + }); + + await this._saveMany(suggestions); + + return suggestions; + } +} + +export default SuggestionCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion.model.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.model.js similarity index 73% rename from packages/spacecat-shared-data-access/src/v2/models/suggestion.model.js rename to packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.model.js index c06d1ef2..fcab57ff 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/suggestion.model.js +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.model.js @@ -10,7 +10,22 @@ * governing permissions and limitations under the License. */ -import BaseModel from './base.model.js'; +import BaseModel from '../base/base.model.js'; + +export const STATUSES = { + NEW: 'NEW', + APPROVED: 'APPROVED', + SKIPPED: 'SKIPPED', + FIXED: 'FIXED', + ERROR: 'ERROR', +}; + +export const TYPES = { + CODE_CHANGE: 'CODE_CHANGE', + CONTENT_UPDATE: 'CONTENT_UPDATE', + REDIRECT_UPDATE: 'REDIRECT_UPDATE', + METADATA_UPDATE: 'METADATA_UPDATE', +}; /** * Suggestion - A class representing a Suggestion entity. diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.schema.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.schema.js new file mode 100644 index 00000000..437ee34d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.schema.js @@ -0,0 +1,53 @@ +/* + * 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 { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Suggestion, { STATUSES, TYPES } from './suggestion.model.js'; +import SuggestionCollection from './suggestion.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/ + */ + +const schema = new SchemaBuilder(Suggestion, SuggestionCollection) + .addReference('belongs_to', 'Opportunity', ['status', 'rank']) + .addAttribute('type', { + type: Object.values(TYPES), + required: true, + readOnly: true, + }) + .addAttribute('rank', { + type: 'number', + required: true, + }) + .addAttribute('data', { + type: 'any', + required: true, + validate: (value) => isNonEmptyObject(value), + }) + .addAttribute('kpiDeltas', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAttribute('status', { + type: Object.values(STATUSES), + required: true, + default: STATUSES.NEW, + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/readme.md b/packages/spacecat-shared-data-access/src/v2/readme.md old mode 100644 new mode 100755 index 3c4de64a..e9cb2c05 --- a/packages/spacecat-shared-data-access/src/v2/readme.md +++ b/packages/spacecat-shared-data-access/src/v2/readme.md @@ -1,272 +1,217 @@ -# ElectroDB Model Framework - -This repository contains a model framework built using the ElectroDB ORM, designed to manage website improvements in a scalable manner. The system consists of several entities, including Opportunities and Suggestions, which represent potential areas of improvement and the actions to resolve them. - -## Table of Contents - -1. [Architecture Overview](#architecture-overview) -2. [Entities and Relationships](#entities-and-relationships) -3. [Getting Started](#getting-started) -4. [Adding a New ElectroDB-Based Entity](#adding-a-new-electrodb-based-entity) - - [Step 1: Define the Entity Schema](#step-1-define-the-entity-schema) - - [Step 2: Add a Model Class](#step-2-add-a-model-class) - - [Step 3: Add a Collection Class](#step-3-add-a-collection-class) - - [Step 4: Integrate the Entity into Model Factory](#step-4-integrate-the-entity-into-model-factory) - - [Step 5: Write Unit and Integration Tests](#step-5-write-unit-and-integration-tests) - - [Step 6: Create JSDoc and Update Documentation](#step-6-create-jsdoc-and-update-documentation) - - [Step 7: Run Tests and Verify](#step-7-run-tests-and-verify) - -## Architecture Overview - -The architecture follows a collection-management pattern with ElectroDB, enabling efficient handling of DynamoDB entities. The architecture is organized into the following layers: - -1. **Data Layer**: Uses DynamoDB with ElectroDB to manage schema definitions and data interactions. -2. **Model Layer**: The `BaseModel` provides methods like `save`, `remove`, and manages associations. Entity classes such as `Opportunity` and `Suggestion` extend `BaseModel` for specific features. -3. **Collection Layer**: The `BaseCollection` handles CRUD operations for entities. Specialized collections, like `OpportunityCollection` and `SuggestionCollection`, extend `BaseCollection` with tailored methods for specific entities. -4. **Factory Layer**: The `ModelFactory` centralizes instantiation of models and collections, providing a unified interface for different entity types. - -### Architectural Diagram - -```plaintext -+--------------------+ -| Data Layer | -|--------------------| -| DynamoDB + ElectroDB ORM | -+--------------------+ - ↓ -+--------------------+ -| Collection Layer | -|--------------------| -| BaseCollection, | -| OpportunityCollection, | -| SuggestionCollection | -+--------------------+ - ↓ -+--------------------+ -| Model Layer | -|--------------------| -| BaseModel, | -| Opportunity, | -| Suggestion | -+--------------------+ - ↓ -+--------------------+ -| Factory Layer | -|--------------------| -| ModelFactory | -+--------------------+ +# ElectroDB Entity Framework + +## Overview + +This entity framework streamlines the definition, querying, and manipulation of domain entities in a DynamoDB-based application. Built atop [ElectroDB](https://electrodb.dev/), it provides a consistent layer for schema definition, indexing, and robust CRUD operations, while adding conveniences like automatic indexing methods and reference handling. + +By adhering to this framework’s conventions, you can introduce and manage new entities with minimal boilerplate and complexity. + +## Core Concepts + +### Entities +An *entity* represents a domain concept (e.g., `User`, `Organization`, `Order`) persisted in the database. Each entity is defined by a schema, specifying attributes, indexes, and references to other entities. The schema integrates with ElectroDB, ensuring a uniform approach to modeling data. + +### Models +A *Model* is a class representing a single instance of an entity. It provides: + +- Attribute getters and setters generated based on the schema. +- Methods for persisting changes (`save()`), and removing entities (`remove()`). +- Methods to fetch referenced entities (via `belongs_to`, `has_one`, `has_many` references). + +Models extend `BaseModel`, which handles most of the common logic. + +### Collections +A *Collection* operates on sets of entities. While `Model` focuses on individual records, `Collection` is for batch and query-level operations: + +- Query methods like `findById()`, `all()`, and index-derived methods. +- Batch creation and update methods (`createMany`, `_saveMany`). +- Automatic generation of `allBy...` and `findBy...` convenience methods based on defined indexes. + +Collections extend `BaseCollection`, which generates query methods at runtime based on your schema definitions. + +### Schema Builder +The `SchemaBuilder` is a fluent API to define an entity’s schema: + +- **Attributes:** Configure entity fields and their validation. +- **Indexes:** Specify primary and secondary indexes for common queries. +- **References:** Define entity relationships (e.g., `User` belongs to `Organization`). + +The `SchemaBuilder` enforces naming conventions and sets defaults, reducing repetitive configuration. + +**Note on Indexes:** Add indexes thoughtfully. Every extra index adds cost and complexity. Only create indexes for well-understood, frequently-needed query patterns. + +### Entity Registry +The `EntityRegistry` aggregates all entities, their schemas, and their collections. It ensures consistent lookup and retrieval of any registered entity’s collection. When you add a new entity, you must register it with the `EntityRegistry` so the rest of the application can discover it. + +## Default Attributes and Indexes + +When you create a schema with `SchemaBuilder`, the following attributes are automatically defined: + +1. **ID (Primary Key):** A UUID-based primary key (`${entityName}Id`), ensuring unique identification. +2. **createdAt:** A timestamp (ISO string) set at entity creation. +3. **updatedAt:** A timestamp (ISO string) updated on each modification. + +A primary index is also set up, keyed by the `${entityName}Id` attribute, guaranteeing a straightforward way to retrieve entities by their unique ID. + +## Auto-Generated Methods + +### `BaseCollection` + +`BaseCollection` automatically generates `allBy...` and `findBy...` methods derived from your defined indexes. For example, if your schema defines an index composed of `opportunityId`, `status`, and `createdAt`, `BaseCollection` will generate: + +- `allByOpportunityId(opportunityId, options?)` +- `findByOpportunityId(opportunityId, options?)` +- `allByOpportunityIdAndStatus(opportunityId, status, options?)` +- `findByOpportunityIdAndStatus(opportunityId, status, options?)` +- `allByOpportunityIdAndStatusAndCreatedAt(opportunityId, status, createdAt, options?)` +- `findByOpportunityIdAndStatusAndCreatedAt(opportunityId, status, createdAt, options?)` + +**allBy...** methods return arrays of matching entities, while **findBy...** methods return a single (or the first matching) entity. Both can accept an optional `options` object for filtering, ordering, attribute selection, and pagination. + +**Example:** +```js +const Suggestion = dataAccess.Suggestion; + +// Retrieve all suggestions by `opportunityId` +const results = await Suggestion.allByOpportunityId('op-12345'); + +// Retrieve a single suggestion by `opportunityId` and `status` +const single = await Suggestion.findByOpportunityIdAndStatus('op-12345', 'OPEN'); ``` -## Entities and Relationships - -- **Opportunity**: Represents a specific issue identified on a website. It includes attributes like `title`, `description`, `siteId`, and `status`. -- **Suggestion**: Represents a proposed fix for an Opportunity. Attributes include `opportunityId`, `type`, `status`, and `rank`. -- **Relationships**: Opportunities have many Suggestions. This relationship is implemented through `OpportunityCollection` and `SuggestionCollection`, which interact via ElectroDB-managed DynamoDB relationships. - -## Getting Started - -1. **Install Dependencies** - ```bash - npm install - ``` - -2. **Setup DynamoDB** - - Ensure AWS credentials are configured and a DynamoDB table is set up. - - Configure the DynamoDB table name and related settings in `index.js`. - -3. **Usage Example** - ```javascript - import { createDataAccess } from './index.js'; - - const config = { tableNameData: 'YOUR_TABLE_NAME' }; - const log = console; - const dao = createDataAccess(config, log); - - // Create a new Opportunity - const opportunityData = { title: 'Broken Links', siteId: 'site123', type: 'broken-backlinks' }; - const newOpportunity = await dao.Opportunity.create(opportunityData); - console.log('New Opportunity Created:', newOpportunity); - ``` - -4. **Extending Functionality** - - Add new models by extending `BaseModel` and new collections by extending `BaseCollection`. - - Register new models in the `ModelFactory` for unified access. - -## Adding a New ElectroDB-Based Entity - -This guide provides a step-by-step overview for adding a new ElectroDB-based entity to the application. - -### Step 1: Define the Entity Schema - -1. **Create Entity Schema File**: Define the entity schema in a new file (e.g., `myNewEntity.schema.js`) within the `/schemas/` directory. - - ```javascript - export const MyNewEntitySchema = { - model: { - entity: 'MyNewEntity', - service: 'MyService', - version: '1', - }, - attributes: { - myNewEntityId: { - type: 'string', - required: true, - }, - name: { - type: 'string', - required: true, - }, - status: { - type: 'string', - enum: ['NEW', 'IN_PROGRESS', 'COMPLETED'], - required: true, - }, - createdAt: { - type: 'string', - required: true, - default: () => new Date().toISOString(), - }, - }, - indexes: { - myNewEntityIndex: { - pk: { - field: 'pk', - facets: ['myNewEntityId'], - }, - sk: { - field: 'sk', - facets: ['status'], - }, - }, - }, - references: { - belongs_to: [ - { type: 'belongs_to', target: 'Opportunity' }, - ], - }, - }; - ``` - -2. **Declare References**: Use the `references` field to define relationships between entities. This sets up associations for easy fetching and managing of related entities, allowing for automatic generation of reference getter methods. - -### Step 2: Add a Model Class - -1. **Create the Model Class**: In the `/models/` directory, add `myNewEntity.model.js`. - - ```javascript - import BaseModel from './base.model.js'; - - class MyNewEntity extends BaseModel { - constructor(electroService, modelFactory, record, log) { - super(electroService, modelFactory, record, log); - } - } - - export default MyNewEntity; - ``` - - Note: By using `BaseModel`, entity classes can remain empty unless there is a need to: - - Override automatically generated getters or setters for specific attributes. - - Add custom methods specific to the entity. - -### Automatic Getter and Setter Methods - -The `BaseModel` automatically generates getter and setter methods for each attribute defined in the entity schema: - -- **Utility Methods**: `BaseModel` provides `getId()`, `getCreatedAt()`, and `getUpdatedAt()` methods out of the box for accessing common entity information like the unique identifier, creation timestamp, and last update timestamp. - -- **Getters**: Follow the convention `get()` to access attribute values. -- **Setters**: Follow the convention `set(value)` to modify entity values, while handling patching. - -Example: - -- If an attribute is named `name`, `BaseModel` will automatically generate: - - `getName()`: Retrieve the value of `name`. - - `setName(value)`: Update the value of `name`. - -This reduces boilerplate and ensures consistency. - -### Automatic Reference Getter Methods - -If references are defined in the schema (e.g., `belongs_to`, `has_many`), `BaseModel` generates reference getter methods: - -- **References Getter Naming**: - - Methods are named `get()`, where `` corresponds to the target specified in the `references` field. - - Example: - ```javascript - references: { - belongs_to: [ - { type: 'belongs_to', target: 'Opportunity' }, - ], - }, - ``` - This results in a `getOpportunity()` method for accessing the related `Opportunity` entity. - -### Step 3: Add a Collection Class - -1. **Create the Collection Class**: Add `myNewEntity.collection.js` in the `/collections/` directory. - - ```javascript - import BaseCollection from './base.collection.js'; - import MyNewEntity from '../models/myNewEntity.model.js'; - - class MyNewEntityCollection extends BaseCollection { - constructor(service, modelFactory, log) { - super(service, modelFactory, MyNewEntity, log); - } - - async allByStatus(status) { - return this.findByIndexKeys({ status }); - } - } - - export default MyNewEntityCollection; - ``` +### `BaseModel` -### Step 4: Integrate the Entity into Model Factory +`BaseModel` provides methods for CRUD operations and reference handling: -1. **Update the Model Factory**: Open `model.factory.js` and add the new entity and collection to the `initialize` method. +- `save()`: Persists changes to the entity. +- `remove()`: Deletes the entity from the database. +- `get...()`: Getters for entity attributes. +- `set...()`: Setters for entity attributes. - ```javascript - import MyNewEntityCollection from './collections/myNewEntity.collection.js'; +Additionally, `BaseModel` generates methods to fetch referenced entities. +For example, if `User` belongs to `Organization`, `BaseModel` will create: - class ModelFactory { - initialize() { - const myNewEntityCollection = new MyNewEntityCollection( - this.service, - this, - this.logger, - ); +- `getOrganization()`: Fetch the referenced `Organization` entity. +- `getOrganizationId()`: Retrieve the `Organization` ID. +- `setOrganizationId(organizationId)`: Update the `Organization` reference. - this.models.set(MyNewEntityCollection.name, myNewEntityCollection); - } - } - ``` +Conversely, the `Organization` entity will have: -### Step 5: Write Unit and Integration Tests +- `getUsers()`: Fetch all `User` entities referencing this `Organization`. +- And with the `User`-Schema's `belongs_to` reciprocal reference expressing filterable sort keys, e.g. "email", "location": + - `getUsersByEmail(email)`: Fetch all `User` entities referencing this `Organization` with a specific email." + - `getUsersByEmailAndLocation(email, location)`: Fetch all `User` entities referencing this `Organization` with a specific email and location. -1. **Create Unit Tests**: Add a file named `myNewEntity.model.test.js` in `/tests/unit/models/` to test all getters, setters, and interactions. - - Use Mocha, Chai, and Sinon for testing. +**Example:** +```js +const user = await User.findById('usr-abc123'); -2. **Create Collection Tests**: Add `myNewEntity.collection.test.js` to `/tests/unit/collections/`. - - Test methods interacting with ElectroDB, like `allByStatus`. +// Work with attributes +console.log(user.getEmail()); // e.g. "john@example.com" +user.setName('John Smith'); +await user.save(); -3. **Add Integration Tests**: Create an integration test file named `myNewEntity.integration.test.js` in `/tests/integration/` to test the full lifecycle of the entity. +// Fetch referenced entity +const org = await user.getOrganization(); +console.log(org.getName()); +``` -### Step 6: Create JSDoc and Update Documentation +## Step-by-Step: Adding a New Entity -1. **Generate JSDoc for Entity and Collection**: Add JSDoc comments for each function to describe the API. -2. **Update Type Definitions**: Modify `index.d.ts` to include new interfaces and types for the entity. +Follow these steps to introduce a new entity into the framework. -### Step 7: Run Tests and Verify +### 1. Define the Schema +Create `user.schema.js`: + +```js +import SchemaBuilder from '../base/schema.builder.js'; +import User from './user.model.js'; +import UserCollection from './user.collection.js'; + +const userSchema = new SchemaBuilder(User, UserCollection) + .addAttribute('email', { + type: 'string', + required: true, + validate: (value) => value.includes('@'), + }) + .addAttribute('name', { type: 'string', required: true }) + .addAllIndexWithComposite('email') + .addReference('belongs_to', 'Organization') // Adds organizationId and byOrganizationId index + .build(); + +export default userSchema; +``` + +### 2. Implement the Model +Create `user.model.js`: + +```js +import BaseModel from '../base/base.model.js'; + +class UserModel extends BaseModel { + // Additional domain logic methods can be added here if needed. +} + +export default UserModel; +``` + +### 3. Implement the Collection +Create `user.collection.js`: + +```js +import BaseCollection from '../base/base.collection.js'; +import UserModel from './user.model.js'; +import userSchema from './user.schema.js'; + +class UserCollection extends BaseCollection { + // Additional domain logic collection methods can be added here if needed. + async findByEmail(email) { + return this.findByIndexKeys({ email }); + } +} + +export default UserCollection; +``` + +### 4. Register the Entity +In `entity.registry.js` (or equivalent): + +```js +import UserSchema from '../user/user.schema.js'; +import UserCollection from '../user/user.collection.js'; + +EntityRegistry.registerEntity(UserSchema, UserCollection); +``` + +### 5. Update DynamoDB Configuration and `schema.json` + +After defining indexes in the schema, **manually add these indexes to your DynamoDB table configuration**. DynamoDB does not automatically create GSIs. You must: + +- Use the AWS Console, CLI, or CloudFormation/Terraform templates to define these GSIs. +- Update your `schema.json` or another documentation file to reflect the newly created indexes, so the team knows which indexes exist and what query patterns they support. + +### 6. Use the Entity +```js +const { User, Organization } = dataAccess; + +// Create a user +const newUser = await User.create({ email: 'john@example.com', name: 'John Doe' }); + +// Find user by ID +const user = await User.findById(newUser.getId()); + +// Get the user organization +const org = await user.getOrganization(); + +// ...or in reverse +const anOrg = await Organization.findById(user.getOrganizationId()); +const orgUsers = await anOrg.getUsers(); + +// Update user and save +user.setName('John X. Doe'); +await user.save(); +``` -1. **Run All Tests**: - ```bash - npm run test && npm run test:it - ``` +## Consideration for Indexes -2. **Run Linter**: Check for coding standard violations. - ```bash - npm run lint - ``` +Indexes cost money and complexity. Do not add indexes lightly. Determine which query patterns you truly need and only then introduce additional indexes. diff --git a/packages/spacecat-shared-data-access/src/v2/schema/opportunity.schema.js b/packages/spacecat-shared-data-access/src/v2/schema/opportunity.schema.js deleted file mode 100644 index 251ae83f..00000000 --- a/packages/spacecat-shared-data-access/src/v2/schema/opportunity.schema.js +++ /dev/null @@ -1,159 +0,0 @@ -/* - * 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 { isNonEmptyObject, isValidUrl } from '@adobe/spacecat-shared-utils'; - -import { validate as uuidValidate, v4 as uuid } from 'uuid'; - -/* -Schema Doc: https://electrodb.dev/en/modeling/schema/ -Attribute Doc: https://electrodb.dev/en/modeling/attributes/ -Indexes Doc: https://electrodb.dev/en/modeling/indexes/ - */ - -const OpportunitySchema = { - model: { - entity: 'Opportunity', - version: '1', - service: 'SpaceCat', - }, - attributes: { - opportunityId: { - type: 'string', - required: true, - readOnly: true, - // https://electrodb.dev/en/modeling/attributes/#default - default: () => uuid(), - // https://electrodb.dev/en/modeling/attributes/#attribute-validation - validate: (value) => uuidValidate(value), - }, - siteId: { - type: 'string', - required: true, - validate: (value) => uuidValidate(value), - }, - auditId: { - type: 'string', - required: true, - validate: (value) => uuidValidate(value), - }, - runbook: { - type: 'string', - validate: (value) => !value || isValidUrl(value), - }, - type: { - type: 'string', - readOnly: true, - required: true, - }, - data: { - type: 'any', - required: false, - validate: (value) => !value || isNonEmptyObject(value), - }, - origin: { - type: ['ESS_OPS', 'AI', 'AUTOMATION'], - required: true, - }, - title: { - type: 'string', - required: true, - }, - description: { - type: 'string', - required: false, - }, - status: { - type: ['NEW', 'IN_PROGRESS', 'IGNORED', 'RESOLVED'], - required: true, - default: () => 'NEW', - }, - guidance: { - type: 'any', - required: false, - validate: (value) => !value || isNonEmptyObject(value), - }, - tags: { - type: 'set', - items: 'string', - required: false, - }, - createdAt: { - type: 'number', - readOnly: true, - required: true, - default: () => Date.now(), - set: () => Date.now(), - }, - updatedAt: { - type: 'number', - watch: '*', - required: true, - default: () => Date.now(), - set: () => Date.now(), - }, - // todo: add createdBy, updatedBy and auto-set from auth context - }, - indexes: { - primary: { // operates on the main table, no 'index' property - pk: { - field: 'pk', - composite: ['opportunityId'], - }, - sk: { - field: 'sk', - composite: [], - }, - }, - bySiteId: { - index: 'spacecat-data-opportunity-by-site', - pk: { - field: 'gsi1pk', - composite: ['siteId'], - }, - sk: { - field: 'gsi1sk', - composite: ['opportunityId'], - }, - }, - bySiteIdAndStatus: { - index: 'spacecat-data-opportunity-by-site-and-status', - pk: { - field: 'gsi2pk', - composite: ['siteId', 'status'], - }, - sk: { - field: 'gsi2sk', - composite: ['updatedAt'], - }, - }, - }, -}; - -/** - * References to other entities. This is not part of the standard ElectroDB schema, but is used - * to define relationships between entities in our data layer API. - * @type {{belongs_to: [{type: string, target: string}]}} - */ -OpportunitySchema.references = { - has_many: [ - { type: 'has_many', target: 'Suggestions' }, - ], - belongs_to: [ - { type: 'belongs_to', target: 'Site' }, - { type: 'belongs_to', target: 'Audit' }, - ], -}; - -export default OpportunitySchema; diff --git a/packages/spacecat-shared-data-access/src/v2/schema/suggestion.schema.js b/packages/spacecat-shared-data-access/src/v2/schema/suggestion.schema.js deleted file mode 100644 index 1f4df360..00000000 --- a/packages/spacecat-shared-data-access/src/v2/schema/suggestion.schema.js +++ /dev/null @@ -1,132 +0,0 @@ -/* - * 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 { v4 as uuid, validate as uuidValidate } from 'uuid'; -import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; - -/* -Schema Doc: https://electrodb.dev/en/modeling/schema/ -Attribute Doc: https://electrodb.dev/en/modeling/attributes/ -Indexes Doc: https://electrodb.dev/en/modeling/indexes/ - */ - -const SuggestionSchema = { - model: { - entity: 'Suggestion', - version: '1', - service: 'SpaceCat', - }, - attributes: { - suggestionId: { - type: 'string', - required: true, - readOnly: true, - // https://electrodb.dev/en/modeling/attributes/#default - default: () => uuid(), - // https://electrodb.dev/en/modeling/attributes/#attribute-validation - validate: (value) => uuidValidate(value), - }, - opportunityId: { - type: 'string', - required: true, - validate: (value) => uuidValidate(value), - }, - type: { - type: ['CODE_CHANGE', 'CONTENT_UPDATE', 'REDIRECT_UPDATE', 'METADATA_UPDATE'], - required: true, - readOnly: true, - }, - rank: { - type: 'number', - required: true, - }, - data: { - type: 'any', - required: true, - validate: (value) => isNonEmptyObject(value), - }, - kpiDeltas: { - type: 'any', - required: false, - validate: (value) => !value || isNonEmptyObject(value), - }, - status: { - type: ['NEW', 'APPROVED', 'SKIPPED', 'FIXED', 'ERROR'], - required: true, - default: () => 'NEW', - }, - createdAt: { - type: 'number', - readOnly: true, - required: true, - default: () => Date.now(), - set: () => Date.now(), - }, - updatedAt: { - type: 'number', - watch: '*', - required: true, - default: () => Date.now(), - set: () => Date.now(), - }, - // todo: add createdBy, updatedBy and auto-set from auth context - }, - indexes: { - primary: { // operates on the main table, no 'index' property - pk: { - field: 'pk', - composite: ['suggestionId'], - }, - sk: { - field: 'sk', - composite: [], - }, - }, - byOpportunityId: { - index: 'spacecat-data-suggestion-by-opportunity', - pk: { - field: 'gsi1pk', - composite: ['opportunityId'], - }, - sk: { - field: 'gsi1sk', - composite: ['suggestionId'], - }, - }, - byOpportunityIdAndStatus: { - index: 'spacecat-data-suggestion-by-opportunity-and-status', - pk: { - field: 'gsi2pk', - composite: ['opportunityId'], - }, - sk: { - field: 'gsi2sk', - composite: ['status', 'rank'], - }, - }, - }, -}; - -/** - * References to other entities. This is not part of the standard ElectroDB schema, but is used - * to define relationships between entities in our data layer API. - * @type {{belongs_to: [{type: string, target: string}]}} - */ -SuggestionSchema.references = { - belongs_to: [ - { type: 'belongs_to', target: 'Opportunity' }, - ], -}; - -export default SuggestionSchema; diff --git a/packages/spacecat-shared-data-access/src/v2/util/accessor.utils.js b/packages/spacecat-shared-data-access/src/v2/util/accessor.utils.js new file mode 100644 index 00000000..6c63856f --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/util/accessor.utils.js @@ -0,0 +1,158 @@ +/* + * 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, isNonEmptyObject, isNumber } from '@adobe/spacecat-shared-utils'; + +import ValidationError from '../errors/validation.error.js'; + +function validateValue(context, keyName, value) { + const { type } = context.schema.getAttribute(keyName); + const validator = type === 'number' ? isNumber : hasText; + + if (!validator(value)) { + throw new ValidationError(`${keyName} is required`); + } +} + +function parseAccessorArgs(context, requiredKeyNames, args) { + const keys = {}; + for (let i = 0; i < requiredKeyNames.length; i += 1) { + const keyName = requiredKeyNames[i]; + const keyValue = args[i]; + + validateValue(context, keyName, keyValue); + + keys[keyName] = keyValue; + } + + let options = {}; + + if (args.length > requiredKeyNames.length) { + options = args[requiredKeyNames.length]; + } + + return { keys, options }; +} + +function validateConfig(config) { + if (!isNonEmptyObject(config)) { + throw new Error('Config is required'); + } + + const { + collection, context, name, requiredKeys, + } = config; + + if (!isNonEmptyObject(collection)) { + throw new Error('Collection is required'); + } + + if (!isNonEmptyObject(context)) { + throw new Error('Context is required'); + } + + if (!hasText(name)) { + throw new Error('Name is required'); + } + + if (!Array.isArray(requiredKeys)) { + throw new Error('Required keys must be an array'); + } +} + +/** + * Create an accessor for a collection. The accessor can be used to query the collection. + * @param {object} config - The accessor configuration. + * @param {boolean} [config.all=false] - Whether to return all items in the collection. + * @param {boolean} [config.byId=false] - Whether to return an item by ID. + * @param {object} config.collection - The collection to query. + * @param {object} config.context - The context to attach the accessor to. + * @param {object} [config.foreignKey] - The foreign key to use when querying by ID. + * @param {string} config.name - The name of the accessor. + * @param {string[]} [config.requiredKeys] - The required keys for the accessor. + * @throws {Error} - If the configuration is invalid. + * @returns {void} + */ +export function createAccessor(config) { /* eslint-disable no-underscore-dangle */ + validateConfig(config); + + const { + all = false, + byId = false, + collection, + context, + foreignKey, + name, + requiredKeys = [], + } = config; + if (!context._accessorCache) { + Object.defineProperty(context, '_accessorCache', { + enumerable: false, + configurable: true, + writable: true, + value: {}, + }); + } + + const foreignKeys = { + ...isNonEmptyObject(foreignKey) && { [foreignKey.name]: foreignKey.value }, + }; + + const accessor = async (...args) => { + const argsKey = args.length > 0 ? JSON.stringify(args) : '_'; + const cacheKey = `${name}:${argsKey}`; + + if (context._accessorCache[cacheKey] !== undefined) { + return context._accessorCache[cacheKey]; + } + + let result; + + if (byId) { + if (!hasText(foreignKey.value)) { + result = null; + } else { + result = collection.findById(foreignKey.value); + } + } else { + const { keys, options } = parseAccessorArgs(collection, requiredKeys, args); + const allKeys = { ...foreignKeys, ...keys }; + + result = all + ? collection.allByIndexKeys(allKeys, options) + : collection.findByIndexKeys(allKeys, options); + } + + result = await result; + context._accessorCache[cacheKey] = result; + + return result; + }; + + Object.defineProperty( + context, + name, + { + enumerable: false, + configurable: false, + writable: true, + value: accessor, + }, + ); +} + +export function createAccessors(configs, log) { + configs.forEach((config) => { + createAccessor(config); + log.info(`Created accessor ${config.name} for ${config.context.schema.getModelName()} to ${config.collection.schema.getModelName()}`); + }); +} diff --git a/packages/spacecat-shared-data-access/src/v2/util/guards.d.ts b/packages/spacecat-shared-data-access/src/v2/util/guards.d.ts old mode 100644 new mode 100755 index 807c1bba..a67ce031 --- a/packages/spacecat-shared-data-access/src/v2/util/guards.d.ts +++ b/packages/spacecat-shared-data-access/src/v2/util/guards.d.ts @@ -25,6 +25,13 @@ export function guardAny( nullable?: boolean, ): void; +export function guardBoolean( + propertyName: string, + value: never, + entityName: string, + nullable?: boolean, +): void; + export function guardEnum( propertyName: string, value: never, diff --git a/packages/spacecat-shared-data-access/src/v2/util/guards.js b/packages/spacecat-shared-data-access/src/v2/util/guards.js index 5fe29c46..7f679213 100644 --- a/packages/spacecat-shared-data-access/src/v2/util/guards.js +++ b/packages/spacecat-shared-data-access/src/v2/util/guards.js @@ -32,14 +32,16 @@ const checkNullable = (value, nullable) => nullable && (value === null || value */ const checkType = (value, type) => { switch (type) { + case 'any': + return isObject(value); + case 'boolean': + return typeof value === 'boolean'; + case 'map': + return isObject(value); case 'string': return typeof value === 'string'; case 'number': return typeof value === 'number'; - case 'boolean': - return typeof value === 'boolean'; - case 'object': - return isObject(value); default: throw new ValidationError(`Unsupported type: ${type}`); } @@ -59,6 +61,21 @@ export const guardAny = (propertyName, value, entityName, nullable = false) => { } }; +/** + * Validates that a given property is a boolean. + * @param {String} propertyName - Name of the property being validated. + * @param {any} value - The value to validate. + * @param {String} entityName - Name of the entity containing this property. + * @param {boolean} [nullable] - Whether the value is nullable. Defaults to false. + * @throws Will throw an error if the value is not a valid boolean. + */ +export const guardBoolean = (propertyName, value, entityName, nullable = false) => { + if (checkNullable(value, nullable)) return; + if (typeof value !== 'boolean') { + throw new ValidationError(`Validation failed in ${entityName}: ${propertyName} must be a boolean`); + } +}; + export const guardArray = (propertyName, value, entityName, type = 'string', nullable = false) => { if (checkNullable(value, nullable)) return; if (!Array.isArray(value)) { diff --git a/packages/spacecat-shared-data-access/src/v2/util/index.js b/packages/spacecat-shared-data-access/src/v2/util/index.js index 8827cb95..e44dfa15 100644 --- a/packages/spacecat-shared-data-access/src/v2/util/index.js +++ b/packages/spacecat-shared-data-access/src/v2/util/index.js @@ -13,6 +13,7 @@ export { guardAny, guardArray, + guardBoolean, guardEnum, guardId, guardMap, diff --git a/packages/spacecat-shared-data-access/src/v2/util/patcher.js b/packages/spacecat-shared-data-access/src/v2/util/patcher.js old mode 100644 new mode 100755 index 84411bcd..d8f6635a --- a/packages/spacecat-shared-data-access/src/v2/util/patcher.js +++ b/packages/spacecat-shared-data-access/src/v2/util/patcher.js @@ -16,6 +16,7 @@ import ValidationError from '../errors/validation.error.js'; import { guardAny, + guardBoolean, guardArray, guardEnum, guardId, @@ -24,6 +25,7 @@ import { guardSet, guardString, } from './index.js'; +import { isNonEmptyArray } from './util.js'; /** * Checks if a property is read-only and throws an error if it is. @@ -39,12 +41,24 @@ const checkReadOnly = (propertyName, attribute) => { }; class Patcher { - constructor(entity, record) { + /** + * Creates a new Patcher instance for an entity. + * @param {object} entity - The entity backing the record. + * @param {Schema} schema - The schema for the entity. + * @param {object} record - The record to patch. + */ + constructor(entity, schema, record) { this.entity = entity; - this.entityName = this.entity.model.name.toLowerCase(); - this.model = entity.model; - this.idName = `${this.model.name.toLowerCase()}Id`; this.record = record; + + this.entityName = schema.getEntityName(); + this.model = entity.model; + this.idName = schema.getIdName(); + + // holds the previous value of updated attributes + this.previous = {}; + + // holds the updates to the attributes this.updates = {}; this.patchRecord = null; @@ -61,24 +75,25 @@ class Patcher { } /** - * Gets the composite values for a given key from the entity schema. * Composite keys have to be provided to ElectroDB in order to update a record across - * multiple indexes. - * @param {Object} record - The record to get the composite values from. - * @param {string} key - The key to get the composite values for. - * @return {{}} - An object containing the composite values for the given key. + * multiple indexes. This method retrieves the composite values for the entity from + * the schema indexes and filters out any values that are being updated. + * @return {{}} - An object containing the composite values for the entity. * @private */ - #getCompositeValuesForKey(record, key) { + #getCompositeValues() { const { indexes } = this.model; const result = {}; const processComposite = (index, compositeType) => { const compositeArray = index[compositeType]?.facets; - if (Array.isArray(compositeArray) && compositeArray.includes(key)) { + if (isNonEmptyArray(compositeArray)) { compositeArray.forEach((compositeKey) => { - if (record[compositeKey] !== undefined) { - result[compositeKey] = record[compositeKey]; + if ( + !Object.keys(this.updates).includes(compositeKey) + && this.record[compositeKey] !== undefined + ) { + result[compositeKey] = this.record[compositeKey]; } }); } @@ -94,18 +109,25 @@ class Patcher { /** * Sets a property on the record and updates the patch record. - * @param {string} propertyName - The name of the property to set. + * @param {string} attribute - The attribute to set. * @param {any} value - The value to set for the property. * @private */ - #set(propertyName, value) { - const compositeValues = this.#getCompositeValuesForKey(this.record, propertyName); - this.patchRecord = this.#getPatchRecord().set({ - ...compositeValues, - [propertyName]: value, - }); - this.record[propertyName] = value; - this.updates[propertyName] = value; + #set(attribute, value) { + this.patchRecord = this.#getPatchRecord().set({ [attribute.name]: value }); + + const update = { + [attribute.name]: { + previous: this.record[attribute.name], + current: value, + }, + }; + + // update the record with the update value for later save + this.record[attribute.name] = value; + + // remember the update operation with the previous and current value + this.updates = { ...this.updates, ...update }; } /** @@ -145,6 +167,9 @@ class Patcher { case 'any': guardAny(propertyName, value, this.entityName, nullable); break; + case 'boolean': + guardBoolean(propertyName, value, this.entityName, nullable); + break; case 'enum': guardEnum(propertyName, value, attribute.enumArray, this.entityName, nullable); break; @@ -168,7 +193,7 @@ class Patcher { } } - this.#set(propertyName, value); + this.#set(attribute, value); } /** @@ -180,8 +205,12 @@ class Patcher { if (!this.hasUpdates()) { return; } - await this.#getPatchRecord().go(); - this.record.updatedAt = new Date().getTime(); + + const compositeValues = this.#getCompositeValues(); + await this.#getPatchRecord() + .composite(compositeValues) + .go(); + this.record.updatedAt = new Date().toISOString(); } getUpdates() { diff --git a/packages/spacecat-shared-data-access/src/v2/util/reference.js b/packages/spacecat-shared-data-access/src/v2/util/reference.js deleted file mode 100644 index c8c6ce24..00000000 --- a/packages/spacecat-shared-data-access/src/v2/util/reference.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 pluralize from 'pluralize'; - -const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1); -const entityNameToCollectionName = (entityName) => `${pluralize.singular(entityName)}Collection`; -const entityNameToIdName = (collectionName) => `${collectionName.charAt(0).toLowerCase() + collectionName.slice(1)}Id`; -const entityNameToReferenceMethodName = (target, type) => { - let baseName = target.charAt(0).toUpperCase() + target.slice(1); - baseName = type === 'has_many' - ? pluralize.plural(baseName) - : pluralize.singular(baseName); - - return `get${baseName}`; -}; - -const idNameToEntityName = (idName) => capitalize(pluralize.singular(idName.replace('Id', ''))); - -const keyNamesToIndexName = (keyNames) => { - const capitalizedKeyNames = keyNames.map((keyName) => capitalize(keyName)); - return `by${capitalizedKeyNames.join('And')}`; -}; - -export { - capitalize, - entityNameToCollectionName, - entityNameToIdName, - entityNameToReferenceMethodName, - idNameToEntityName, - keyNamesToIndexName, -}; diff --git a/packages/spacecat-shared-data-access/src/v2/util/util.js b/packages/spacecat-shared-data-access/src/v2/util/util.js new file mode 100644 index 00000000..1ec8f81c --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/util/util.js @@ -0,0 +1,104 @@ +/* + * 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, isInteger } from '@adobe/spacecat-shared-utils'; +import pluralize from 'pluralize'; + +const capitalize = (str) => (hasText(str) ? str[0].toUpperCase() + str.slice(1) : ''); + +const classExtends = (clazz, base) => (typeof clazz === 'function' && clazz.prototype instanceof base); + +const decapitalize = (str) => (hasText(str) ? str[0].toLowerCase() + str.slice(1) : ''); + +const collectionNameToEntityName = (collectionName) => collectionName.replace('Collection', ''); + +const entityNameToCollectionName = (entityName) => `${capitalize(pluralize.singular(entityName))}Collection`; + +const entityNameToIdName = (entityName) => `${decapitalize(pluralize.singular(entityName))}Id`; + +const referenceToBaseMethodName = (reference) => { + const target = capitalize(reference.getTarget()); + const baseName = reference.getType() === 'has_many' + ? pluralize.plural(target) + : pluralize.singular(target); + + return `get${baseName}`; +}; + +const entityNameToAllPKValue = (entityName) => `ALL_${pluralize.plural(entityName.toUpperCase())}`; + +const idNameToEntityName = (idName) => capitalize(pluralize.singular(idName.replace('Id', ''))); + +const isPositiveInteger = (value) => isInteger(value) && value > 0; + +const keyNamesToIndexName = (keyNames) => `by${keyNames.map(capitalize).join('And')}`; + +const keyNamesToMethodName = (keyNames, prefix) => prefix + keyNames.map(capitalize).join('And'); + +const modelNameToEntityName = (modelName) => decapitalize(modelName); + +const removeElectroProperties = (record) => { /* eslint-disable no-underscore-dangle */ + const cleanedRecord = { ...record }; + + delete cleanedRecord.sk; + delete cleanedRecord.pk; + delete cleanedRecord.gsi1pk; + delete cleanedRecord.gsi1sk; + delete cleanedRecord.gsi2pk; + delete cleanedRecord.gsi2sk; + delete cleanedRecord.gsi3pk; + delete cleanedRecord.gsi3sk; + delete cleanedRecord.gsi4pk; + delete cleanedRecord.gsi4sk; + delete cleanedRecord.__edb_e__; + delete cleanedRecord.__edb_v__; + + return cleanedRecord; +}; + +const sanitizeTimestamps = (data) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { createdAt, updatedAt, ...rest } = data; + return rest; +}; + +const sanitizeIdAndAuditFields = (entityName, data) => { + const idName = entityNameToIdName(entityName); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [idName]: _, ...rest } = data; + return sanitizeTimestamps(rest); +}; + +const incrementVersion = (version) => (isInteger(version) ? parseInt(version, 10) + 1 : 1); + +const isNonEmptyArray = (value) => Array.isArray(value) && value.length > 0; + +export { + capitalize, + classExtends, + collectionNameToEntityName, + decapitalize, + entityNameToAllPKValue, + entityNameToCollectionName, + entityNameToIdName, + idNameToEntityName, + incrementVersion, + isNonEmptyArray, + isPositiveInteger, + keyNamesToIndexName, + keyNamesToMethodName, + modelNameToEntityName, + referenceToBaseMethodName, + removeElectroProperties, + sanitizeIdAndAuditFields, + sanitizeTimestamps, +}; diff --git a/packages/spacecat-shared-data-access/test/fixtures/api-keys.fixtures.js b/packages/spacecat-shared-data-access/test/fixtures/api-keys.fixtures.js new file mode 100644 index 00000000..cb89acba --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/api-keys.fixtures.js @@ -0,0 +1,58 @@ +/* + * 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 apiKeys = [ + { + name: 'Test API Key 1', + expiresAt: '2025-10-09T19:21:55.834Z', + hashedApiKey: 'some-key-1', + imsOrgId: 'org-1@AdobeOrg', + imsUserId: 'user-1', + scopes: [{ + name: 'imports.read', + }, + { + name: 'imports.write', + domains: ['https://example.com'], + }], + }, + { + name: 'Test API Key 2', + expiresAt: '2025-10-09T19:21:55.834Z', + hashedApiKey: 'some-key-2', + imsOrgId: 'org-2@AdobeOrg', + imsUserId: 'user-2', + scopes: [{ + name: 'imports.read', + }, + { + name: 'imports.write', + domains: ['https://example.com'], + }], + }, + { + name: 'Test API Key 3', + expiresAt: '2025-10-09T19:21:55.834Z', + hashedApiKey: 'some-key-2', + imsOrgId: 'org-1@AdobeOrg', + imsUserId: 'user-1', + scopes: [{ + name: 'imports.read', + }, + { + name: 'imports.write', + domains: ['https://example-3.com'], + }], + }, +]; + +export default apiKeys; diff --git a/packages/spacecat-shared-data-access/test/fixtures/audits.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/audits.fixture.js new file mode 100755 index 00000000..6f81e66e --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/audits.fixture.js @@ -0,0 +1,1411 @@ +/* + * 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 audits = [ + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.01, + seo: 0.56, + accessibility: 0.23, + 'best-practices': 0.09, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/49a5a731-e2f2-41ef-bc5d-bda818c0afa2.json', + auditId: '3fe5ca60-4850-431c-97b3-f88a80f07e9b', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.58, + seo: 0.89, + accessibility: 0.83, + 'best-practices': 0.35, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/d86ff424-76a5-45aa-8bae-817415056802.json', + auditId: '48656b02-62cb-46c0-b271-ee99c940e89e', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.13, + seo: 0.91, + accessibility: 0.38, + 'best-practices': 0.51, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/ace35131-98c8-4578-8bc9-06537f1cffb4.json', + auditId: '5bc610a9-bc59-48d8-937e-4808ade2ecb1', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.1, + seo: 0.34, + accessibility: 0.24, + 'best-practices': 0.6, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/4f861df7-d074-472b-8df8-b96e8c132145.json', + auditId: '62cc5af2-935f-47dd-b60e-87307f39c475', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.51, + seo: 0.3, + accessibility: 0.71, + 'best-practices': 0.63, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/80284b70-0e3c-49f8-b470-8c073f002b7d.json', + auditId: '82250098-ca65-4bef-ada9-71c30102b334', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3815, + FID: 35, + CLS: 0.56, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/9f3ef6ed-d6e6-4fcc-a9ef-fab2e0955104.json', + auditId: '5ab73d44-41ab-4603-8c28-76e2707b3182', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1723, + FID: 49, + CLS: 0.97, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/604e4a2b-47d5-479d-bab0-2bc03b41392a.json', + auditId: 'd141c82e-5290-4352-9a81-a5400436c07c', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1485, + FID: 2, + CLS: 0, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/30486fe9-72f1-4ddb-91c8-8c41cf9e4a3a.json', + auditId: '44d76d98-56cf-4c3d-ab6b-a2a8ee459bed', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1893, + FID: 20, + CLS: 0.35, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/b43b8240-6d83-4aac-9f8e-2ca7d89c1994.json', + auditId: '523396a7-5b30-4e12-a439-ffb1336c6902', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 714, + FID: 73, + CLS: 0.88, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/82ed94be-979e-4ce3-9c90-1919fefb855a.json', + auditId: '998ec567-d32a-4645-a627-81c20794e6ea', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.25, + seo: 0.53, + accessibility: 0.82, + 'best-practices': 0.92, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/ab0420e5-97fb-48f2-9d9f-90e8d54e08c1.json', + auditId: '00e6591d-f334-4c74-8446-f31c3e689e99', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.19, + seo: 0.33, + accessibility: 0.18, + 'best-practices': 0.71, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/9f190e2c-ed87-43b0-88a5-65480bd90115.json', + auditId: 'b136b63a-5e67-46c0-80b9-68f1699d09c1', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.62, + seo: 0.91, + accessibility: 0.69, + 'best-practices': 0.97, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/04b6b484-ec63-4bd1-9e3b-cf10aa247837.json', + auditId: '759caa14-8a41-4bee-ba87-ec60b8231b6a', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.67, + seo: 0.61, + accessibility: 0.45, + 'best-practices': 0.25, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/4f177b54-9b24-4b99-9fb5-222594819735.json', + auditId: '0bd56305-8486-4b23-abc1-19789efb2807', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.41, + seo: 0, + accessibility: 0.04, + 'best-practices': 0.12, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/32699c46-07cd-4fc0-a71f-4a77356aa3e7.json', + auditId: '73980def-db81-4b5b-b66d-2b94602c2261', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1830, + FID: 66, + CLS: 0.13, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/67cd3ab8-67d6-46be-adc2-c13dea7adcc0.json', + auditId: '54cab615-8608-4d67-a999-b49235217adf', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1398, + FID: 22, + CLS: 0.45, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c70adc49-046f-4ade-ab3e-e72f38f025fe.json', + auditId: '2cc9ab3c-8d46-4ac7-83d7-a1231c91d34c', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2543, + FID: 84, + CLS: 0.34, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/02e9d522-911d-43a6-8d72-11e181c947e0.json', + auditId: '059dcce7-a1a4-4224-904e-fc56620f929d', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 108, + FID: 37, + CLS: 0.32, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/5ce74f5e-a728-4a75-b7f6-4a02b3f25bd7.json', + auditId: '147bd40e-90b5-4e9d-abe1-df30cd16d095', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3979, + FID: 13, + CLS: 0.12, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/761d24ec-5bb7-4ef6-ab8b-0ce0bc5ac336.json', + auditId: '31e257a7-534e-44ed-90a9-d24c849e246d', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.79, + seo: 0.16, + accessibility: 0.7, + 'best-practices': 0.54, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/9afcfa59-8516-4d76-a960-842ed559eba6.json', + auditId: 'c125fe6e-3768-43a5-ae8f-3448e01c8a1f', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.57, + seo: 0.46, + accessibility: 0.46, + 'best-practices': 0.21, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/bd35024c-cce8-44cc-901c-321c7f25c56e.json', + auditId: '857d3742-0757-4fc0-a7dc-2b73720d37f0', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.45, + seo: 0.8, + accessibility: 0.88, + 'best-practices': 0.33, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/46c92f4e-eb76-4511-b296-cbcb65c47c04.json', + auditId: '8aadfc9f-85e9-4ce8-9b7e-2f9243c57b29', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.76, + seo: 0.89, + accessibility: 0.71, + 'best-practices': 0.51, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/89239d70-b2b6-4776-840d-439963f04a8e.json', + auditId: '29d218aa-416a-4811-866e-0890485d21e0', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.28, + seo: 0.24, + accessibility: 0.64, + 'best-practices': 0.79, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/a937a24e-7eed-4436-8455-176f0e6719c6.json', + auditId: 'c6548198-de76-4a32-8053-e8d101afbd68', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1195, + FID: 0, + CLS: 0.8, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/4674d92c-bf33-424b-9662-95ec1d11cbf7.json', + auditId: '88ee2b0e-61ba-49a3-a2b4-79163418fe87', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 187, + FID: 16, + CLS: 0.55, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/84734df6-3fd6-47fd-bd13-339b8fe22298.json', + auditId: '5b6e75e7-a0c0-414a-bc4c-16543e70b61a', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3294, + FID: 18, + CLS: 0.27, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/f73d8165-5197-43ab-a09d-ad950a5e6ce7.json', + auditId: '7c70acfb-f40a-4102-abc2-69f79c720bf9', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3997, + FID: 32, + CLS: 0.16, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/f2428633-0646-41ba-81c5-7fbc81e00a98.json', + auditId: 'cc1755f3-386c-427e-b2eb-e0b3c5515533', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3730, + FID: 73, + CLS: 0.33, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/a716c3af-3f5a-4fa6-b408-4a5955cd4dd1.json', + auditId: '6d43f172-3e86-45d8-83cd-6d006fb8cdad', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.06, + seo: 0.46, + accessibility: 0.85, + 'best-practices': 0.91, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/dadcdcd7-fe40-4166-91fb-f0f8b2f237da.json', + auditId: '761c7cc8-7ad5-4a24-aae8-90a1b0b47e9a', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.26, + seo: 0.3, + accessibility: 0.1, + 'best-practices': 0.51, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/70df143f-f2a1-43e6-b9b8-3a56e83f67a9.json', + auditId: 'd8e4e662-8148-471e-a1c2-e75be4fb1d1a', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.54, + seo: 0.8, + accessibility: 0.44, + 'best-practices': 0.9, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/eb2a16ab-c44a-4486-b9f3-83447634d6e0.json', + auditId: 'a6bfc5e8-8d9e-4f22-b549-8bf4ca7b5c66', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.72, + seo: 0.55, + accessibility: 0.27, + 'best-practices': 0.02, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/aad60d6a-3be8-425a-8769-07942f4d6ff3.json', + auditId: '9113159b-a93d-4d1f-aa6f-72575eefd3b3', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.63, + seo: 0.48, + accessibility: 0.93, + 'best-practices': 0.12, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/aae172c2-c2e8-4ddb-998e-8890e8298c5f.json', + auditId: 'aba03683-da1d-467b-b7a5-24f857f016e1', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1624, + FID: 42, + CLS: 0.8, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c537c3b4-1156-4397-936f-11aff4e5a22e.json', + auditId: 'aa36a3c7-ed2b-4290-8985-86bdb7fa3881', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 711, + FID: 46, + CLS: 0.32, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/39906f6b-ed1a-4736-814a-013ec919119f.json', + auditId: '90168755-49bc-48a2-b7f3-9852be99c8af', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1213, + FID: 84, + CLS: 0.6, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/14f602ca-48be-4bd5-97e0-ac2eff4a6dd7.json', + auditId: '54d580be-285c-40fb-a3b5-ec91768d4fa2', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2642, + FID: 65, + CLS: 0.03, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/ee38a119-01f1-4ad6-81e5-209b28a76563.json', + auditId: 'b6bc9260-5424-4fdb-9d2c-ed20084f6583', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2144, + FID: 22, + CLS: 0.06, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/bc82ffe7-c764-4baf-b0b2-4bd815ad756c.json', + auditId: 'dad35375-fc74-482b-bd22-946e1c013fdd', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.1, + seo: 0.04, + accessibility: 0.99, + 'best-practices': 0.3, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/8802e432-7b64-4116-81e6-029076d6250f.json', + auditId: '30dcaef5-49a1-41ec-8656-eee6d6480d0a', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.3, + seo: 0.54, + accessibility: 0.25, + 'best-practices': 0.97, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c6a8d133-b079-4229-99a6-819ed63249ae.json', + auditId: '3fb08b5a-303d-4f2c-8e73-d929a4eff024', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.56, + seo: 0.02, + accessibility: 0.6, + 'best-practices': 0.21, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/6a8dbfed-f957-47ec-ba48-a60b2009d7a0.json', + auditId: '6a87be71-611f-4b05-a6cf-86a57eb349ed', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.04, + seo: 0.32, + accessibility: 0.01, + 'best-practices': 0.97, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/474bd0b2-4faf-4f37-bed4-a670d2a09186.json', + auditId: '43fd913f-5b14-4f18-9cc3-d49891cc4288', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.68, + seo: 0.79, + accessibility: 0.43, + 'best-practices': 0.64, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/1dabbb60-f530-4f53-84c5-0f168b02309b.json', + auditId: '554b8e9d-98b8-4dd5-9d58-c9ee57160530', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2550, + FID: 42, + CLS: 0.49, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/cb9671f5-29ce-49fa-9386-e9512ef72938.json', + auditId: '4f5f307b-a865-4362-bb25-f5e25db64230', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 914, + FID: 91, + CLS: 0.49, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/b61cfab3-12c5-4fe6-a434-5efcec1e1b2d.json', + auditId: '295db381-d5fb-465a-a5da-2d9adbe04038', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 965, + FID: 43, + CLS: 0.63, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/929daee0-ca67-4134-91a0-28a0827655e4.json', + auditId: '4941bddf-dd5e-45cc-9ef8-1c416fd48a5f', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1957, + FID: 69, + CLS: 0.42, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2f7d425f-c806-4e8f-b973-484a3c7e456a.json', + auditId: 'b3431f8b-a338-45c7-a1fd-c4c6bb3bb56a', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2579, + FID: 38, + CLS: 0.22, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/fc342da0-6bde-4a49-aa8f-66e7e20b8b62.json', + auditId: '67c86f77-1ee0-4441-bdd2-adff90863f57', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.87, + seo: 0.25, + accessibility: 0.21, + 'best-practices': 0.98, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/eb8414da-fb40-4ee5-a9d2-1f88ca8e5cca.json', + auditId: '9b4be774-4585-4980-9f60-0881c6f34954', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.03, + seo: 0.47, + accessibility: 0.3, + 'best-practices': 0.41, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/0cd4b808-0352-420b-b3d5-897c233edbcf.json', + auditId: '3b84b1b1-75ed-42af-acf3-144b9966289a', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.01, + seo: 0.56, + accessibility: 0.47, + 'best-practices': 0.64, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/52afcf01-0309-4bc6-aab6-45771115b983.json', + auditId: '25a953af-2374-4d98-b146-00efb64a08c0', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.16, + seo: 0.25, + accessibility: 0.42, + 'best-practices': 0.52, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/25832af5-2913-4757-8687-61e3fe5abb48.json', + auditId: 'ff7ed730-7304-4ff8-8e49-161504fffbc9', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.5, + seo: 0.83, + accessibility: 0.23, + 'best-practices': 0.48, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/967a1183-88a9-4a34-a207-f32de8e09c87.json', + auditId: '152cbd10-912e-4269-97e0-29915ec41004', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3892, + FID: 15, + CLS: 0.27, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c37892f9-1f9c-45d8-8cd1-98a4d5d6ca78.json', + auditId: 'f19a8348-a864-4f32-b2fc-24a9e477796e', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3471, + FID: 64, + CLS: 0.98, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/e33cc9b6-1e6a-4f57-a676-2c1b54e6af9e.json', + auditId: '88cf9014-19d8-4ed1-a0ab-7162ac3dc735', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3889, + FID: 14, + CLS: 0.31, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/351b1d0b-9059-4ce5-8cea-695b694d26b1.json', + auditId: '1653f24e-42a9-4f43-aaed-8940494eeeb2', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3776, + FID: 74, + CLS: 0.55, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2c3f3b3c-4ec6-4294-ad75-51ef66c3da22.json', + auditId: '3617955b-b575-4af3-80c9-06dacc2b32d5', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 618, + FID: 43, + CLS: 0.07, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c9f2530c-6ef9-4056-bc93-ecdbf825d50d.json', + auditId: 'f641393c-9533-4b31-9565-6cf2d6fa7448', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.59, + seo: 0.5, + accessibility: 0.15, + 'best-practices': 0.94, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/146a1be4-cada-4953-b2e0-675e129e761f.json', + auditId: '3743317e-d122-430d-ba07-52f3f0e098e0', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.51, + seo: 0.91, + accessibility: 0.08, + 'best-practices': 0.93, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/e7c6a9c8-8379-4b1a-804d-2db7051383ea.json', + auditId: 'b4f8ac21-679d-4c5d-a84b-354e29500e7c', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.65, + seo: 0.16, + accessibility: 0.79, + 'best-practices': 0.84, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/5f47dfbc-64bd-4673-96e4-822f81e046d0.json', + auditId: 'a3159352-0aa1-440a-8983-52b1d4d1728a', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.99, + seo: 0.31, + accessibility: 0.07, + 'best-practices': 0.81, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/0a45a27b-2b30-428b-9306-309771a66533.json', + auditId: '7fae8262-8e15-4776-8f3a-759f94519873', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.49, + seo: 0.43, + accessibility: 0.41, + 'best-practices': 0.78, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/d69c7946-be1c-48e8-bd08-9b445112289d.json', + auditId: 'c758d7a1-5c18-4f31-854e-f386527a4c24', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 279, + FID: 0, + CLS: 0.18, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/475a14ef-8017-43a1-b633-dce3e9f323c9.json', + auditId: '5e267293-a534-4b5a-90b0-424281eaa4d1', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 699, + FID: 96, + CLS: 0.5, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2baaa974-990f-4ce9-b941-43f50ca26106.json', + auditId: 'c3cee208-4d98-4527-8ccd-7b09da29b913', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2319, + FID: 57, + CLS: 0.46, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/5d1c4e35-7165-4751-adb7-0d63b5b4539d.json', + auditId: 'de9f3e43-3a7f-4863-9ae8-44351d917f72', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3871, + FID: 82, + CLS: 0.29, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/de5150f0-536c-4cc8-aca3-aae14b2f2e3f.json', + auditId: '9943f084-f1d0-4b5f-a610-a06b2acd8a84', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1480, + FID: 46, + CLS: 0.31, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/56e55cc1-9052-4036-a0e3-3d17c06e76e9.json', + auditId: '4bc151ce-86bb-4718-a3e0-4270cf14ab31', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.25, + seo: 0.74, + accessibility: 0.66, + 'best-practices': 0.12, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/349f82bc-03ac-4957-a267-8157e2ffbba7.json', + auditId: 'ef3e04a5-2b1f-449e-979c-55b33b341b3d', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.64, + seo: 0.47, + accessibility: 0.44, + 'best-practices': 0.06, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c62d53b2-b9ac-4636-b1af-f7c4b982d746.json', + auditId: '3343fd4b-3185-49b4-b6c5-cd75b3a7b342', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.93, + seo: 0.36, + accessibility: 0.56, + 'best-practices': 0.34, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/af269848-fd03-4fe9-a702-22d38c2efd4b.json', + auditId: '6c7c0771-2561-44c8-bd58-5a61ab2227cf', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.01, + seo: 0.92, + accessibility: 0.63, + 'best-practices': 0.55, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/bcd8b08f-35aa-4d5a-aff6-8f184434b8e2.json', + auditId: 'fde1401c-f2e4-4250-ae41-ca637a2fbcfd', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.33, + seo: 0.8, + accessibility: 0.96, + 'best-practices': 0.22, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/fc312358-b9b9-4268-911a-c63f709baa3b.json', + auditId: '4d38967a-85c0-4e89-a20f-4f247b2a1bb8', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 380, + FID: 67, + CLS: 0.25, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/0440c39c-f15e-4b71-8a74-60f4ece1478c.json', + auditId: 'ac445f94-441b-46b4-9ce9-f1cc2e9390ea', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3996, + FID: 59, + CLS: 0.27, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/25c8bc2f-ec0c-432e-87bd-6b608d46bcf4.json', + auditId: 'f055cd12-0f2b-4043-8f34-bc892c4175a0', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3317, + FID: 92, + CLS: 0.76, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/52c305f5-f943-45a1-87d7-caf396a50c61.json', + auditId: '6f8d6d0c-7cf5-46cb-90a0-d864362ed5f5', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 729, + FID: 97, + CLS: 0.13, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2045f404-51c5-4f2f-a344-468d6be86f87.json', + auditId: 'fc688025-4fa9-4a77-b958-8f8b8ecce657', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 496, + FID: 45, + CLS: 0.82, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/ec87b5b7-88c1-4f0d-8aff-a7ac33fc7401.json', + auditId: '90ded6b5-f45b-4ef9-b66b-876f64ecd9cc', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.12, + seo: 0.99, + accessibility: 0.3, + 'best-practices': 0.89, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/6e86047d-d210-44ed-9f62-ab75e6ff3d3a.json', + auditId: 'f3749899-7fc5-4b05-b467-1e2410471713', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.27, + seo: 0.87, + accessibility: 0.47, + 'best-practices': 0.1, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/6b54f635-5d1a-4f02-a1cb-e3e970444b9e.json', + auditId: '2320706e-a629-42df-82d2-e032478a999a', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.33, + seo: 0.8, + accessibility: 0.88, + 'best-practices': 0.94, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/5a60159a-3cec-4ef1-8e45-89d558e5f5c5.json', + auditId: 'ff435772-20e6-47b4-96ae-e37c0016a749', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.31, + seo: 0.99, + accessibility: 0.19, + 'best-practices': 0.82, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/81acc17a-3fb5-4392-870a-1da8d28e2aeb.json', + auditId: '5e6e5a16-67b9-4e5e-bb6d-e8ae3436a69e', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.62, + seo: 0.3, + accessibility: 0.25, + 'best-practices': 0.44, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/472d9af6-1a0d-45a6-a3ae-ab1f239dae4a.json', + auditId: '65a6e8ea-7d26-4d9b-80db-b2c2b96bddfb', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2357, + FID: 5, + CLS: 0.49, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2c140c93-cd08-4ded-94e6-d3be6238adb6.json', + auditId: '32f6355b-d187-4436-bec0-9a5e5ae3ba0c', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1542, + FID: 96, + CLS: 0.56, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/911dffec-7318-4665-a8e8-0ae799ca0f9a.json', + auditId: '0a9c4880-2e91-42ae-ad8b-4bb7df1dba9c', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1996, + FID: 90, + CLS: 0.52, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/db57f2b8-4ff6-429c-ad43-4b3360288278.json', + auditId: 'bcfbdc07-f665-415b-925d-7d30409769ca', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3898, + FID: 65, + CLS: 0.54, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/8d783998-f58f-4199-89a1-f6c240318bd3.json', + auditId: '24bf0a9d-efc3-4585-ad5e-88c4037be72d', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2464, + FID: 28, + CLS: 0.85, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2e074190-3e94-4545-b099-b63dcf443565.json', + auditId: '144d0a42-05cd-4166-a879-cca18dc0b31a', + }, +]; + +export default audits; diff --git a/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js new file mode 100644 index 00000000..bcdf0452 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js @@ -0,0 +1,111 @@ +/* + * 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 configurations = [ + { + configurationId: '3c29b306-5075-4a2d-a965-730d0e565e7f', + jobs: [ + { + group: 'audits', + type: 'lhs-mobile', + interval: 'daily', + }, + { + group: 'audits', + type: '404', + interval: 'daily', + }, + { + group: 'imports', + type: 'rum-ingest', + interval: 'daily', + }, + { + group: 'reports', + type: '404-external-digest', + interval: 'weekly', + }, + { + group: 'audits', + type: 'apex', + interval: 'weekly', + }, + ], + handlers: { + 404: { + enabledByDefault: true, + }, + 'organic-keywords': { + enabledByDefault: false, + }, + cwv: { + enabledByDefault: true, + disabled: { + sites: [ + '5d6d4439-6659-46c2-b646-92d110fa5a52', + '78fec9c7-2141-4600-b7b1-ea5c78752b91', + '56a691db-d32e-4308-ac99-a21de0580557', + '196fb401-ede2-4607-9d25-7c011a65d143', + 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + '3429cedf-06b0-489f-b066-81cada1634fc', + '73bd9bba-40bb-4249-bc69-7ea0f130481d', + 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + 'b197d10e-035e-433b-896f-8e4967c5de6a', + ], + orgs: ['757ceb98-05c8-4e07-bb23-bc722115b2b0'], + }, + }, + 'lhs-mobile': { + enabledByDefault: false, + enabled: { + sites: ['c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe'], + orgs: ['757ceb98-05c8-4e07-bb23-bc722115b2b0'], + }, + }, + }, + queues: { + audits: 'sqs://.../spacecat-services-audit-jobs', + imports: 'sqs://.../spacecat-services-import-jobs', + reports: 'sqs://.../spacecat-services-report-jobs', + }, + slackRoles: { + scrape: [ + 'WSVT1K36Z', + 'S03CR0FDC2V', + ], + }, + version: 2, + }, + { + configurationId: 'a76a5b01-d065-4349-a28f-f1beaf96aee6', + jobs: [ + { + group: 'audits', + type: 'lhs-mobile', + interval: 'daily', + }, + { + group: 'reports', + type: '404-external-digest', + interval: 'weekly', + }, + ], + queues: { + audits: 'sqs://.../spacecat-services-audit-jobs', + reports: 'sqs://.../spacecat-services-report-jobs', + }, + version: 1, + }, +]; + +export default configurations; diff --git a/packages/spacecat-shared-data-access/test/fixtures/experiments.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/experiments.fixture.js new file mode 100755 index 00000000..1b1ca121 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/experiments.fixture.js @@ -0,0 +1,136 @@ +/* + * 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 experiments = [ + { + experimentId: '745292e2-52af-4b66-b63b-fca68019a42b', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + expId: 'experiment-1', + name: 'Experiment 1', + url: 'https://example0.com/page-1', + status: 'ACTIVE', + type: 'full', + variants: [ + { + label: 'Challenger 1', + name: 'challenger-1', + interactionsCount: 10, + p_value: 'coming soon', + split: 0.8, + url: 'https://example0.com/page-1/variant-1', + views: 100, + metrics: [ + { + selector: '.header .button', + type: 'click', + value: 2, + }, + ], + }, + { + label: 'Challenger 2', + name: 'challenger-2', + interactionsCount: 20, + p_value: 'coming soon', + metrics: [], + split: 0.8, + url: 'https://example0.com/page-2/variant-2', + views: 200, + }, + ], + startDate: '2024-11-29T07:45:55.952Z', + endDate: '2024-12-09T07:45:55.954Z', + updatedBy: 'scheduled-experiment-audit', + }, + { + experimentId: '3451b539-df79-4033-b300-82904f7a3840', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + expId: 'experiment-2', + name: 'Experiment 2', + url: 'https://example0.com/page-2', + status: 'ACTIVE', + type: 'full', + variants: [ + { + label: 'Challenger 2', + name: 'challenger-2', + interactionsCount: 20, + p_value: 'coming soon', + split: 0.8, + url: 'https://example0.com/page-2/variant-2', + views: 200, + metrics: [ + { + selector: '.header .button', + type: 'click', + value: 4, + }, + ], + }, + { + label: 'Challenger 3', + name: 'challenger-3', + interactionsCount: 30, + p_value: 'coming soon', + metrics: [], + split: 0.8, + url: 'https://example0.com/page-3/variant-3', + views: 300, + }, + ], + startDate: '2024-11-29T07:45:55.952Z', + endDate: '2024-12-09T07:45:55.954Z', + updatedBy: 'scheduled-experiment-audit', + }, + { + experimentId: '111385e5-5680-48bd-8a77-f6b69df6f1b7', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + expId: 'experiment-3', + name: 'Experiment 3', + url: 'https://example0.com/page-3', + status: 'ACTIVE', + type: 'full', + variants: [ + { + label: 'Challenger 3', + name: 'challenger-3', + interactionsCount: 30, + p_value: 'coming soon', + split: 0.8, + url: 'https://example0.com/page-3/variant-3', + views: 300, + metrics: [ + { + selector: '.header .button', + type: 'click', + value: 6, + }, + ], + }, + { + label: 'Challenger 4', + name: 'challenger-4', + interactionsCount: 40, + p_value: 'coming soon', + metrics: [], + split: 0.8, + url: 'https://example0.com/page-4/variant-4', + views: 400, + }, + ], + startDate: '2024-11-29T07:45:55.952Z', + endDate: '2024-12-09T07:45:55.954Z', + updatedBy: 'scheduled-experiment-audit', + }, +]; + +export default experiments; diff --git a/packages/spacecat-shared-data-access/test/fixtures/import-jobs.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/import-jobs.fixture.js new file mode 100644 index 00000000..e38562e9 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/import-jobs.fixture.js @@ -0,0 +1,67 @@ +/* + * 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 { ImportJobStatus, ImportOptions } from '../../src/index.js'; + +const importJobs = [ + { + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + importQueueId: 'Q-123', + hashedApiKey: '1234', + baseURL: 'https://example-1.com/cars', + startedAt: '2023-12-06T08:17:41.467Z', + status: ImportJobStatus.RUNNING, + initiatedBy: { + apiKeyName: 'K-123', + }, + options: { + [ImportOptions.ENABLE_JAVASCRIPT]: true, + }, + hasCustomImportJs: true, + hasCustomHeaders: false, + }, + { + importJobId: '72113a4d-ca45-4c35-bd2e-29bb0ec03435', + importQueueId: 'Q-321', + hashedApiKey: '4321', + baseURL: 'https://example-2.com/cars', + startedAt: '2023-11-15T01:22:05.000Z', + status: ImportJobStatus.FAILED, + initiatedBy: { + apiKeyName: 'K-321', + }, + options: { + [ImportOptions.ENABLE_JAVASCRIPT]: false, + }, + hasCustomImportJs: false, + hasCustomHeaders: true, + }, + { + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + importQueueId: 'Q-213', + hashedApiKey: '4231', + baseURL: 'https://example-3.com/', + startedAt: '2023-11-15T03:46:40.000Z', + endedAt: '2023-11-15T03:49:13.000Z', + status: ImportJobStatus.COMPLETE, + initiatedBy: { + apiKeyName: 'K-322', + }, + options: { + [ImportOptions.ENABLE_JAVASCRIPT]: false, + }, + hasCustomImportJs: false, + hasCustomHeaders: true, + }, +]; + +export default importJobs; diff --git a/packages/spacecat-shared-data-access/test/fixtures/import-urls.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/import-urls.fixture.js new file mode 100644 index 00000000..207bf1a2 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/import-urls.fixture.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 { ImportUrlStatus } from '../../src/index.js'; + +const importUrls = [ + { + importUrlId: 'dd92aba6-5509-44a5-afbb-f56e6c4544ed', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/1', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: '531b69c9-0059-42cf-a19d-302d932e22c7', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/2', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: '4cb51b53-f8c6-4975-841d-6ca54489aba4', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/3', + status: ImportUrlStatus.PENDING, + }, + { + importUrlId: '7aab39a1-a677-461c-a79c-ee7d64c4dd35', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/4', + status: ImportUrlStatus.PENDING, + }, + { + importUrlId: '5ffc1fa0-9920-43c5-8228-f13354dd2f25', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/5', + status: ImportUrlStatus.FAILED, + }, + // 2 + { + importUrlId: '59896102-0f4b-4fff-a4cb-e45fd3b5b6b0', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-2.com/cars/1', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: '033f7342-c49e-45fd-8026-19b8220bf887', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-2.com/cars/2', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: 'f38a0810-21c9-4bf6-bdeb-6c0c32c38f62', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-2.com/cars/3', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: '480f058f-dde3-4149-ace0-25b14f13d597', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-2.com/cars/4', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: 'c5b2c409-6074-4379-a06d-06ca85e8b5d6', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-1.com/cars/5', + status: ImportUrlStatus.STOPPED, + }, +]; + +export default importUrls; diff --git a/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js new file mode 100644 index 00000000..222b78f6 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js @@ -0,0 +1,41 @@ +/* + * 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 apiKeys from './api-keys.fixtures.js'; +import audits from './audits.fixture.js'; +import configurations from './configurations.fixture.js'; +import experiments from './experiments.fixture.js'; +import importJobs from './import-jobs.fixture.js'; +import importUrls from './import-urls.fixture.js'; +import keyEvents from './key-events.fixture.js'; +import opportunities from './opportunities.fixture.js'; +import organizations from './organizations.fixture.js'; +import siteCandidates from './site-candidates.fixture.js'; +import siteTopPages from './site-top-pages.fixture.js'; +import sites from './sites.fixture.js'; +import suggestions from './suggestions.fixture.js'; + +export default { + apiKeys, + audits, + configurations, + experiments, + importJobs, + importUrls, + keyEvents, + opportunities, + organizations, + siteCandidates, + siteTopPages, + sites, + suggestions, +}; diff --git a/packages/spacecat-shared-data-access/test/fixtures/key-events.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/key-events.fixture.js new file mode 100755 index 00000000..48326ac0 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/key-events.fixture.js @@ -0,0 +1,716 @@ +/* + * 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 keyEvents = [ + { + keyEventId: '3b2d9cb2-5610-4b49-b138-0a1ff45221d1', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4061ea02-e03a-46e9-9443-a9ef412c79c5', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '67c4b2d2-658f-4e68-beab-cccd41df9e1e', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '2b0f0503-fa45-440f-afda-4877194afcb0', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '480e907b-4409-4ff8-b7ce-f65320381b8e', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5ceb13e6-75ec-4cf1-90ee-4e8113e2f2c2', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '8b32b3b3-004d-4c8a-a536-ff1501392b92', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'be4a159e-d44d-49ce-8900-7de644632f9f', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '21c77742-4249-4ad5-a747-7abe5154fa2b', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'f4c42af3-4b18-404f-bf88-d87155eaf640', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4581066b-250a-422a-92d9-995dc9b0d4e4', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b4d3a7ca-59ce-4e6c-ac28-a9a1bf17777c', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'fffd8fc5-a007-47a6-9569-594ce3bd1e8d', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b740d9f0-3652-4430-a7f7-5941f1b420ab', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '440e092d-4f13-4fa5-b564-7713edd27fd0', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e72558e7-0856-4565-bb77-353f1e75e1c7', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '7c2b17f8-40b5-4a3e-bcba-ed0a1d6571d6', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0f176d01-cb8d-47c3-99c3-c3298469de71', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5bdea93b-96a1-4533-a5e9-a19135332077', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '22b86430-f63a-4ecf-b61c-e9417bb5f489', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'a75743a2-1b85-4b9d-9be0-9bf61185c2a9', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'a1e7c306-ceca-4fcd-bb96-88eb55205759', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '47dfb1ba-c3b9-4a68-88a3-9a8406665910', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '9c9c6f9d-7674-4da6-9cc7-3510a771db15', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0645e469-aac3-423b-b390-c3f402eaa4ec', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'eee6e3e4-c70c-4224-9b04-d81520e3138a', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '36aeacfd-5d50-482d-9a17-b4bfe7b09db4', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '1a4afdda-db0b-4ff3-91a1-86e822cb16d6', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '8f6d504d-6673-494b-85de-3e04f7105a71', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '402eeeca-6447-4c75-9786-0d93e03ce287', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '78903cbf-0fb1-4c4b-8da0-0e4afc4facc8', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '17b635af-0745-49ae-bae5-dd793e61979c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0bf69424-b638-4062-ab11-9d9d06c03445', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '84cfcbd5-23e5-4ce1-9a8f-8a599fd63d53', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0d2bebd1-54c3-43a9-938d-c7610b1adf6d', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '57356d04-1b6c-4de7-8e83-8ff9fcccd81c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '9788a4c8-1c19-4684-8672-b287ca1cfc40', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'fb716741-d971-4514-b59b-2473261a579c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '3ad1595f-f403-4b76-8184-8f811d71b01c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '681c9e03-c56c-40e5-88e8-343fb797c212', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'c4e63c52-c415-4ff1-9c8a-023871595155', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '50546ecc-114b-46bb-9a7a-9b48ce3cc520', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '22d2d6f5-aefb-4a7a-baa6-cdcad14bc870', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0848dc29-4020-415f-a773-d2c8b029454d', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0e0b5396-01ec-4f55-8a14-46d39689f852', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e56724f8-a75f-4c3b-88ca-f28e513c9b68', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '764894f0-cc55-491f-ae9b-d1fc07c56041', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'd92d0b8e-1216-4d50-b659-42c3a8f46dbf', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'd73e6a54-d7aa-47db-b6df-b45685c9dc9e', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '103863f5-67c7-4b04-9d8a-aad840df7175', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '7d5adc14-6e8c-4376-bc90-68a4bf5cd6bd', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '27940406-877b-45af-beb3-4ba2c49d4316', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'c4a691b9-48d5-44d5-9ac1-8e1d9b4c46e4', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4d83f283-6b83-4785-b1fa-f522ceb9fa57', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '52a19d57-f746-492d-8241-aaf3d697ef15', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b04d4630-c4a4-4c05-a1b5-3e54ddd70694', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '8a15adc7-dc62-46c7-8d46-a189f59c9865', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'a57a1442-7819-42d0-91df-f42469d50dbb', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b083ea44-0966-41bb-a54d-ccb519f3e74c', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4f9e24af-737a-494f-aa3f-4413dd3bfa03', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'fbdee0fe-5782-4f23-98a7-3010706fb191', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'd3ba693c-e664-49a2-a95e-8e320aa24896', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e99f12ef-17e3-42f4-94f5-21208cdf2e45', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e59f7864-9012-49a4-8cd2-d3a5e9231fc1', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '04fab020-29b7-4ca1-aa8e-86d90d4c5fa7', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '8ba99dd5-fcff-4d4e-af41-0df6877ca012', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '3443a217-a6c1-4710-9387-31b307fecc7c', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e708be0a-69b8-4ae6-af98-7ed9671446c2', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'c5db543d-2364-4d50-bd95-54fac1498e2c', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '79bbe5f8-8a98-49ba-a1b1-b7ae0ad91af5', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '9c19aea5-8205-436e-b404-fcab49d4040f', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5e85d6f4-3f30-463a-9e14-060d217cc8d1', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'f1343c61-f242-46d2-b31a-afa536a4f632', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'cbfcce79-8398-4e90-aecb-00057631c132', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '76b0f029-53e5-49a6-b92e-03cdf2e5bc2c', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b652a304-8cc9-4ff6-889c-3b48abf6cf68', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '3e030a5b-7b8a-456c-9f0d-e3e1850889c8', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '640fdf68-d92b-4b56-a55e-2ff337780a16', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'a5d2a5d7-f83d-4832-a87e-30cde0585c0d', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4f6df4e7-7a6f-4e93-aba4-54de4b3c34f3', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '142d8755-aabb-4919-8e1b-9f18a4811764', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b9e20c4e-6a07-4bbf-9881-842605b925f9', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'fe66e789-0566-4abc-938d-4af7303d3a30', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '9940d958-6b6e-48c8-b680-b2f166bc29b3', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'd02fee8f-5301-4737-84f3-b23eea9dd0e2', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b348bd8c-059e-4fe2-ab93-1f262284aeec', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '534fea70-c9ca-4768-8599-9214cd86a7f0', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e72d4510-15e1-4c77-bb3c-3206124814d1', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'ec0e865d-b4aa-4297-a213-f9e1cab0e557', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e41b7b1f-b7d9-4999-a678-f0fd588239b4', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '111860ee-1b0d-47b2-9a52-32d9c38569be', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '922fef39-61e8-4208-a039-94daf0d43b34', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '7e27e616-db48-4945-ad72-d2fa8491340a', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b0799c94-44e6-4ffd-9234-af75d8cf71aa', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '6739a542-a962-41f1-a45f-e89233eb2f82', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5e782e9b-f2f2-459b-8bf4-7f4ee36e5b9f', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4f05cd5a-79fd-47f2-bfa9-7adf11035a74', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '510b7f04-30d9-461a-8a5b-14cdb4a8330b', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5704be25-2836-44ca-8e1e-bda3823be0f3', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '553ef634-9a08-416f-848f-6cc97384d9fc', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, +]; + +export default keyEvents; diff --git a/packages/spacecat-shared-data-access/test/fixtures/opportunities.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/opportunities.fixture.js new file mode 100644 index 00000000..902bf354 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/opportunities.fixture.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. + */ + +const opportunities = [ + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + auditId: '3fe5ca60-4850-431c-97b3-f88a80f07e9b', + opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', + title: 'Opportunity 0', + description: 'Description 0', + runbook: 'https://example0.com', + type: 'broken-backlinks', + origin: 'AI', + guidance: { + foo: 'bar-0', + }, + status: 'NEW', + data: { + brokenLinks: [ + 'foo-0', + ], + }, + }, + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + auditId: '48656b02-62cb-46c0-b271-ee99c940e89e', + opportunityId: '742c49a7-d61f-4c62-9f7c-3207f520ed1e', + title: 'Opportunity 1', + description: 'Description 1', + runbook: 'https://example1.com', + type: 'broken-internal-links', + origin: 'AI', + guidance: { + foo: 'bar-1', + }, + status: 'IN_PROGRESS', + data: { + brokenInternalLinks: [ + 'bar-1', + ], + }, + }, + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + auditId: '5bc610a9-bc59-48d8-937e-4808ade2ecb1', + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + title: 'Opportunity 2', + description: 'Description 2', + runbook: 'https://example2.com', + type: 'broken-backlinks', + origin: 'AI', + guidance: { + foo: 'bar-2', + }, + status: 'NEW', + data: { + brokenLinks: [ + 'foo-2', + ], + }, + }, +]; + +export default opportunities; diff --git a/packages/spacecat-shared-data-access/test/fixtures/organizations.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/organizations.fixture.js new file mode 100644 index 00000000..6fd5c85b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/organizations.fixture.js @@ -0,0 +1,106 @@ +/* + * 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 organizations = [ + { + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + imsOrgId: '0-1234@AdobeOrg', + name: '0-1234Name', + config: + { + slack: + { + workspace: '0-workspace', + channel: '0-channel', + }, + handlers: + { + 404: + { + mentions: + { + slack: + [ + '0-slackId', + ], + }, + }, + 'organic-keywords': + { + country: 'RO', + }, + }, + }, + }, + { + organizationId: '757ceb98-05c8-4e07-bb23-bc722115b2b0', + imsOrgId: '1-1234@AdobeOrg', + name: '1-1234Name', + config: + { + slack: + { + workspace: '1-workspace', + channel: '1-channel', + }, + handlers: + { + 404: + { + mentions: + { + slack: + [ + '1-slackId', + ], + }, + }, + 'organic-keywords': + { + country: 'RO', + }, + }, + }, + }, + { + organizationId: '5d42bdf8-b65d-4de8-b849-a4f28ebc93cd', + imsOrgId: '2-1234@AdobeOrg', + name: '2-1234Name', + config: + { + slack: + { + workspace: '2-workspace', + channel: '2-channel', + }, + handlers: + { + 404: + { + mentions: + { + slack: + [ + '2-slackId', + ], + }, + }, + 'organic-keywords': + { + country: 'RO', + }, + }, + }, + }, +]; + +export default organizations; diff --git a/packages/spacecat-shared-data-access/test/fixtures/site-candidates.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/site-candidates.fixture.js new file mode 100644 index 00000000..0488ff42 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/site-candidates.fixture.js @@ -0,0 +1,76 @@ +/* + * 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 siteCandidates = [ + { + siteCandidateId: 'aa3f78ad-e76f-437d-a4e5-9702fe5e80e2', + baseURL: 'https://example0.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'ddc56466-fd1f-49e0-8e6f-aa2e018b5c32', + baseURL: 'https://example1.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: '37ed9927-f2da-4f00-b35a-7c994e3dc73e', + baseURL: 'https://example2.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'bf960709-9ed6-4a39-9804-90cb10824ebe', + baseURL: 'https://example3.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: '4ef9108a-3a89-499f-9d25-85ebc06996f8', + baseURL: 'https://example4.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'ae726216-b4e9-4fad-928c-bdda9a103d7c', + baseURL: 'https://example5.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'a54d1d04-0696-4c85-989e-4670abbb7fa6', + baseURL: 'https://example6.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: '926ea990-7ce2-4ad7-ac8f-444846f004c8', + baseURL: 'https://example7.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'c1a48f74-0b1c-48f3-a4b6-4db128f00226', + baseURL: 'https://example8.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'ae28b64f-9eaa-40f3-93af-033953234a9f', + baseURL: 'https://example9.com', + status: 'PENDING', + source: 'CDN', + }, +]; + +export default siteCandidates; diff --git a/packages/spacecat-shared-data-access/test/fixtures/site-top-pages.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/site-top-pages.fixture.js new file mode 100644 index 00000000..f221bab3 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/site-top-pages.fixture.js @@ -0,0 +1,516 @@ +/* + * 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 siteTopPages = [ + { + siteTopPageId: 'fe732596-d16a-451d-80a2-f4283beb6ee7', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-0', + traffic: 12345, + topKeyword: 'keyword-0', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '688098d1-7778-4857-8ef9-57bc8dd110b8', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-1', + traffic: 24690, + topKeyword: 'keyword-1', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '254a0948-b3c8-45ed-9f47-30158175c77f', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-2', + traffic: 37035, + topKeyword: 'keyword-2', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '7ec3f862-c23a-4332-9892-034efc3c3cda', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-3', + traffic: 49380, + topKeyword: 'keyword-3', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'bebd496d-e230-438e-be32-a9244231dd44', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-4', + traffic: 61725, + topKeyword: 'keyword-4', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '3f28c46f-77f4-4744-8968-c21d8d3553b9', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-5', + traffic: 74070, + topKeyword: 'keyword-5', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'f72c0fb1-3e9b-422d-9a20-4b6e65d1936d', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-6', + traffic: 86415, + topKeyword: 'keyword-6', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '8ad6df6f-69f0-4a39-b4ba-d6486b1329dd', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-7', + traffic: 98760, + topKeyword: 'keyword-7', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'cb959983-ac42-44d8-a767-9b7a368072ca', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-8', + traffic: 111105, + topKeyword: 'keyword-8', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'adba4e9b-c7b5-49a3-b678-a60a15b273f7', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-9', + traffic: 123450, + topKeyword: 'keyword-9', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '1a55e463-f453-4da3-9a11-ab6eb1f38960', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-10', + traffic: 135795, + topKeyword: 'keyword-10', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '55f9b70c-d501-4dee-bdd8-41cfbc663961', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-11', + traffic: 148140, + topKeyword: 'keyword-11', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'c840e7dd-3532-4059-8b0c-611e79bd57a6', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-12', + traffic: 160485, + topKeyword: 'keyword-12', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '9c0f2340-005e-4832-849d-430561fdbf4c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-13', + traffic: 172830, + topKeyword: 'keyword-13', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'b25de0e7-055c-41fc-97e6-7f242aff908e', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-14', + traffic: 185175, + topKeyword: 'keyword-14', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '4066eac5-70e7-4d0f-a1d5-0c79e2c20b7a', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-15', + traffic: 197520, + topKeyword: 'keyword-15', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '0b4d3adf-d162-4f6a-a391-b0ac63cab6cb', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-16', + traffic: 209865, + topKeyword: 'keyword-16', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '1306bb98-cfa4-4c49-9f0e-f7b0821220cc', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-17', + traffic: 222210, + topKeyword: 'keyword-17', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '3ecccce8-b72e-4c4e-8923-d76c91ecf9c6', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-18', + traffic: 234555, + topKeyword: 'keyword-18', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'd204c26a-682d-4218-8e49-90d7229dcd49', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-19', + traffic: 246900, + topKeyword: 'keyword-19', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'c89ba829-6c4b-43fe-af47-dc1538424192', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-20', + traffic: 259245, + topKeyword: 'keyword-20', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'd6ac93ad-b5b9-405d-a2bf-41c1c4ef8371', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-21', + traffic: 271590, + topKeyword: 'keyword-21', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '8fd720ef-8e22-4c32-bda0-f0a52fc17d6a', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-22', + traffic: 283935, + topKeyword: 'keyword-22', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '4d830289-9912-47c1-8151-f511b35593cf', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-23', + traffic: 296280, + topKeyword: 'keyword-23', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'b6e83c2e-657e-431d-9cf6-f9423fbf9fa5', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-24', + traffic: 308625, + topKeyword: 'keyword-24', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '202b661e-fddb-4dd4-a4cd-0212b9558da2', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-25', + traffic: 320970, + topKeyword: 'keyword-25', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'ee59ac4b-ea4b-4e88-88c3-012a35de1601', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-26', + traffic: 333315, + topKeyword: 'keyword-26', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'de4d24fa-18a5-4735-894d-b09d259b9b37', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-27', + traffic: 345660, + topKeyword: 'keyword-27', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '942ddc5a-1d2e-4339-9f24-70e98577333e', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-28', + traffic: 358005, + topKeyword: 'keyword-28', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '5da258e5-e992-4933-a389-542866badd6e', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-29', + traffic: 370350, + topKeyword: 'keyword-29', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '5ea4b10f-00d1-4688-bf67-6fb3a0eeeec1', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-30', + traffic: 382695, + topKeyword: 'keyword-30', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '9689dac9-9c66-44f2-b4dc-476183f2002a', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-31', + traffic: 395040, + topKeyword: 'keyword-31', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'e8d623cc-78aa-4a6e-a1df-38a8f1bf096b', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-32', + traffic: 407385, + topKeyword: 'keyword-32', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '1efce2b3-95a9-47a4-bfb8-158bedf728c7', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-33', + traffic: 419730, + topKeyword: 'keyword-33', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '47580cec-37f6-4655-b344-5cfad4a6f30a', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-34', + traffic: 432075, + topKeyword: 'keyword-34', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '3160fc0e-43c3-48a9-abcc-91ab24ded702', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-35', + traffic: 444420, + topKeyword: 'keyword-35', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '2e3558fd-f992-4f49-99c6-e9448bb06b71', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-36', + traffic: 456765, + topKeyword: 'keyword-36', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '0547dba8-e3f4-4d45-9703-5e00602c1f00', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-37', + traffic: 469110, + topKeyword: 'keyword-37', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'e1815da4-3653-4fd3-9960-f0482b283c68', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-38', + traffic: 481455, + topKeyword: 'keyword-38', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'e1af7167-b886-4891-b91a-f21cf818c134', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-39', + traffic: 493800, + topKeyword: 'keyword-39', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '7beddc90-c87d-4350-a869-1c43be3f4ac6', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-40', + traffic: 506145, + topKeyword: 'keyword-40', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '3318c08f-4fc1-4c81-be9b-3116c2396820', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-41', + traffic: 518490, + topKeyword: 'keyword-41', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '4b8b27db-1d35-47fe-92b1-c684b3627446', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-42', + traffic: 530835, + topKeyword: 'keyword-42', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'b082fe9c-4e90-4abc-8279-9b0f570821b0', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-43', + traffic: 543180, + topKeyword: 'keyword-43', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'cc49c6fd-87ec-493d-8cc8-a3426296947e', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-44', + traffic: 555525, + topKeyword: 'keyword-44', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'f856cbda-dce7-46e8-b000-bb696a02c16b', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-45', + traffic: 567870, + topKeyword: 'keyword-45', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '5bb47ff2-3c37-432f-b924-59df5e840150', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-46', + traffic: 580215, + topKeyword: 'keyword-46', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '1e2e0ccd-1f8b-45fe-aab7-1d05dc84e848', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-47', + traffic: 592560, + topKeyword: 'keyword-47', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'c71f49bc-f216-4eff-8f37-e9e1cef741b4', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-48', + traffic: 604905, + topKeyword: 'keyword-48', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '930340a7-3d1e-4039-993c-778de6b8c80c', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-49', + traffic: 617250, + topKeyword: 'keyword-49', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, +]; + +export default siteTopPages; diff --git a/packages/spacecat-shared-data-access/test/fixtures/sites.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/sites.fixture.js new file mode 100644 index 00000000..04d7486c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/sites.fixture.js @@ -0,0 +1,424 @@ +/* + * 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 sites = [ + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + baseURL: 'https://example0.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-0/test-repo', + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + config: + { + slack: + { + workspace: '0-workspace', + channel: '0-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '0-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + baseURL: 'https://example1.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-1/test-repo', + organizationId: '757ceb98-05c8-4e07-bb23-bc722115b2b0', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '1-workspace', + channel: '1-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '1-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + baseURL: 'https://example2.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-2/test-repo', + organizationId: '5d42bdf8-b65d-4de8-b849-a4f28ebc93cd', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '2-workspace', + channel: '2-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '2-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + baseURL: 'https://example3.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-3/test-repo', + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '3-workspace', + channel: '3-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '3-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + baseURL: 'https://example4.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-4/test-repo', + organizationId: '757ceb98-05c8-4e07-bb23-bc722115b2b0', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '4-workspace', + channel: '4-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '4-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + baseURL: 'https://example5.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-5/test-repo', + organizationId: '5d42bdf8-b65d-4de8-b849-a4f28ebc93cd', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '5-workspace', + channel: '5-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '5-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + baseURL: 'https://example6.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-6/test-repo', + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '6-workspace', + channel: '6-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '6-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + baseURL: 'https://example7.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-7/test-repo', + organizationId: '757ceb98-05c8-4e07-bb23-bc722115b2b0', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '7-workspace', + channel: '7-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '7-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + baseURL: 'https://example8.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-8/test-repo', + organizationId: '5d42bdf8-b65d-4de8-b849-a4f28ebc93cd', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '8-workspace', + channel: '8-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '8-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + baseURL: 'https://example9.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-9/test-repo', + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '9-workspace', + channel: '9-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '9-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, +]; + +export default sites; diff --git a/packages/spacecat-shared-data-access/test/fixtures/suggestions.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/suggestions.fixture.js new file mode 100755 index 00000000..1152d97a --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/suggestions.fixture.js @@ -0,0 +1,115 @@ +/* + * 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 suggestions = [ + { + opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', + title: 'Suggestion 0 for Opportunity 0', + description: 'Description for Suggestion 0 of Opportunity 0', + data: { + foo: 'bar-0', + }, + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + }, + { + opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', + title: 'Suggestion 1 for Opportunity 0', + description: 'Description for Suggestion 1 of Opportunity 0', + data: { + foo: 'bar-1', + }, + type: 'CODE_CHANGE', + rank: 1, + status: 'SKIPPED', + }, + { + opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', + title: 'Suggestion 2 for Opportunity 0', + description: 'Description for Suggestion 2 of Opportunity 0', + data: { + foo: 'bar-2', + }, + type: 'CODE_CHANGE', + rank: 2, + status: 'NEW', + }, + { + opportunityId: '742c49a7-d61f-4c62-9f7c-3207f520ed1e', + title: 'Suggestion 0 for Opportunity 1', + description: 'Description for Suggestion 0 of Opportunity 1', + data: { + foo: 'bar-0', + }, + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + }, + { + opportunityId: '742c49a7-d61f-4c62-9f7c-3207f520ed1e', + title: 'Suggestion 1 for Opportunity 1', + description: 'Description for Suggestion 1 of Opportunity 1', + data: { + foo: 'bar-1', + }, + type: 'CODE_CHANGE', + rank: 1, + status: 'NEW', + }, + { + opportunityId: '742c49a7-d61f-4c62-9f7c-3207f520ed1e', + title: 'Suggestion 2 for Opportunity 1', + description: 'Description for Suggestion 2 of Opportunity 1', + data: { + foo: 'bar-2', + }, + type: 'CODE_CHANGE', + rank: 2, + status: 'NEW', + }, + { + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + title: 'Suggestion 0 for Opportunity 2', + description: 'Description for Suggestion 0 of Opportunity 2', + data: { + foo: 'bar-0', + }, + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + }, + { + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + title: 'Suggestion 1 for Opportunity 2', + description: 'Description for Suggestion 1 of Opportunity 2', + data: { + foo: 'bar-1', + }, + type: 'CODE_CHANGE', + rank: 1, + status: 'NEW', + }, + { + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + title: 'Suggestion 2 for Opportunity 2', + description: 'Description for Suggestion 2 of Opportunity 2', + data: { + foo: 'bar-2', + }, + type: 'CODE_CHANGE', + rank: 2, + status: 'NEW', + }, +]; + +export default suggestions; diff --git a/packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js b/packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js new file mode 100644 index 00000000..e2e3d537 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js @@ -0,0 +1,137 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +describe('ApiKey IT', async () => { + let sampleData; + let ApiKey; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + ApiKey = dataAccess.ApiKey; + }); + + it('adds a new api key', async () => { + const data = { + name: 'Test API Key', + expiresAt: '2025-12-06T08:35:24.125Z', + hashedApiKey: '1234', + imsOrgId: '1234@AdobeOrg', + imsUserId: '1234', + scopes: [ + { name: 'imports.read' }, + { name: 'imports.write', domains: ['https://example.com'] }, + ], + }; + + const apiKey = await ApiKey.create(data); + + expect(apiKey).to.be.an('object'); + expect(apiKey.getId()).to.be.a('string'); + expect(apiKey.getCreatedAt()).to.be.a('string'); + expect(apiKey.getUpdatedAt()).to.be.a('string'); + + expect( + sanitizeIdAndAuditFields('ApiKey', apiKey.toJSON()), + ).to.eql(data); + }); + + it('gets all api keys by imsUserId and imsOrgId', async () => { + const sampleApiKey = sampleData.apiKeys[0]; + const apiKeys = await ApiKey.allByImsOrgIdAndImsUserId( + sampleApiKey.getImsOrgId(), + sampleApiKey.getImsUserId(), + ); + + expect(apiKeys).to.be.an('array'); + expect(apiKeys.length).to.equal(2); + + apiKeys.forEach((apiKey) => { + expect(apiKey.getImsOrgId()).to.equal(sampleApiKey.getImsOrgId()); + expect(apiKey.getImsUserId()).to.equal(sampleApiKey.getImsUserId()); + }); + }); + + it('finds an api key by hashedApiKey', async () => { + const sampleApiKey = sampleData.apiKeys[0]; + const apiKey = await ApiKey.findByHashedApiKey(sampleApiKey.getHashedApiKey()); + + expect(apiKey).to.be.an('object'); + + expect( + sanitizeTimestamps(apiKey.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleApiKey.toJSON()), + ); + }); + + it('finds an api key by its id', async () => { + const sampleApiKey = sampleData.apiKeys[0]; + const apiKey = await ApiKey.findById(sampleApiKey.getId()); + + expect(apiKey).to.be.an('object'); + + expect( + sanitizeTimestamps(apiKey.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleApiKey.toJSON()), + ); + }); + + it('updates an api key', async () => { + const apiKey = await ApiKey.findById(sampleData.apiKeys[0].getId()); + + const data = { + name: 'Updated API Key', + expiresAt: '2024-12-06T08:35:24.125Z', + hashedApiKey: '1234', + imsOrgId: '1234@AdobeOrg', + imsUserId: '1234', + scopes: [ + { name: 'imports.write' }, + { name: 'imports.read', domains: ['https://updated-example.com'] }, + ], + }; + + const result = await apiKey + .setName(data.name) + .setExpiresAt(data.expiresAt) + .setHashedApiKey(data.hashedApiKey) + .setImsOrgId(data.imsOrgId) + .setImsUserId(data.imsUserId) + .setScopes(data.scopes) + .save(); + + expect(result).to.be.an('object'); + + const updatedApiKey = await ApiKey.findById(sampleData.apiKeys[0].getId()); + + expect(updatedApiKey.getId()).to.equal(apiKey.getId()); + + expect( + sanitizeIdAndAuditFields('ApiKey', updatedApiKey.toJSON()), + ).to.eql(data); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/audit/audit.test.js b/packages/spacecat-shared-data-access/test/it/audit/audit.test.js new file mode 100644 index 00000000..0c613b23 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/audit/audit.test.js @@ -0,0 +1,100 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +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 checkAudit(audit) { + expect(audit).to.be.an('object'); + expect(audit.getId()).to.be.a('string'); + expect(audit.getSiteId()).to.be.a('string'); + expect(audit.getAuditType()).to.be.a('string'); + expect(audit.getAuditedAt()).to.be.a('string'); + expect(audit.getAuditResult()).to.be.an('object'); + expect(audit.getScores()).to.be.an('object'); + expect(audit.getFullAuditRef()).to.be.a('string'); + expect(audit.getIsLive()).to.be.a('boolean'); +} + +describe('Audit IT', async () => { + let sampleData; + let Audit; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Audit = dataAccess.Audit; + }); + + it('gets all audits for a site', async () => { + const site = sampleData.sites[1]; + + const audits = await Audit.allBySiteId(site.getId()); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(10); + + audits.forEach((audit) => { + expect(audit.getSiteId()).to.equal(site.getId()); + checkAudit(audit); + }); + }); + + it('gets audits of type for a site', async () => { + const auditType = 'lhs-mobile'; + const site = sampleData.sites[1]; + + const audits = await Audit.allBySiteIdAndAuditType(site.getId(), auditType); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(5); + + audits.forEach((audit) => { + expect(audit.getSiteId()).to.equal(site.getId()); + expect(audit.getAuditType()).to.equal(auditType); + checkAudit(audit); + }); + }); + + it('gets latest audit of type lhs-mobile for a site', async () => { + const auditType = 'lhs-mobile'; + const site = sampleData.sites[1]; + const audits = await site.getAudits(); + const audit = await site.getLatestAuditByType(auditType); + + checkAudit(audit); + + expect(audit.getSiteId()).to.equal(site.getId()); + expect(audit.getAuditType()).to.equal(auditType); + expect(audit.getAuditedAt()).to.equal(audits[5].getAuditedAt()); + }); + + it('returns null for non-existing audit', async () => { + let audit = await Audit.findById('78fec9c7-2141-4600-b7b1-ea4c78752b91'); + + expect(audit).to.be.null; + + const site = sampleData.sites[1]; + audit = await site.getLatestAuditByType('non-existing-type'); + + expect(audit).to.be.null; + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js b/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js new file mode 100644 index 00000000..e334c8bb --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js @@ -0,0 +1,118 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +describe('Configuration IT', async () => { + let sampleData; + let Configuration; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Configuration = dataAccess.Configuration; + }); + + it('gets all configurations', async () => { + const configurations = await Configuration.all(); + + expect(configurations).to.be.an('array'); + expect(configurations).to.have.lengthOf(sampleData.configurations.length); + configurations.forEach((configuration, index) => { + expect( + sanitizeTimestamps(configuration.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.configurations[index].toJSON()), + ); + }); + }); + + it('finds one configuration by version', async () => { + const sampleConfiguration = sampleData.configurations[1]; + const configuration = await Configuration.findByVersion( + sampleConfiguration.getVersion(), + ); + + expect(configuration).to.be.an('object'); + expect( + sanitizeTimestamps(configuration.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleConfiguration.toJSON()), + ); + }); + + it('finds the latest configuration', async () => { + const sampleConfiguration = sampleData.configurations[0]; + const configuration = await Configuration.findLatest(); + + expect(configuration).to.be.an('object'); + expect( + sanitizeTimestamps(configuration.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleConfiguration.toJSON()), + ); + }); + + it('updates a configuration', async () => { + const configuration = await Configuration.findLatest(); + + const data = { + enabledByDefault: true, + enabled: { + sites: ['site1'], + orgs: ['org1'], + }, + }; + + const expectedConfiguration = { + ...configuration.toJSON(), + handlers: { + ...configuration.toJSON().handlers, + test: data, + }, + version: configuration.getVersion() + 1, + }; + + configuration.addHandler('test', data); + + await configuration.save(); + + const updatedConfiguration = await Configuration.findLatest(); + expect(updatedConfiguration.getId()).to.not.equal(configuration.getId()); + expect( + Date.parse(updatedConfiguration.record.createdAt), + ).to.be.greaterThan( + Date.parse(configuration.record.createdAt), + ); + expect( + Date.parse(updatedConfiguration.record.updatedAt), + ).to.be.greaterThan( + Date.parse(configuration.record.updatedAt), + ); + expect( + sanitizeIdAndAuditFields('Configuration', updatedConfiguration.toJSON()), + ).to.eql( + sanitizeIdAndAuditFields('Configuration', expectedConfiguration), + ); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js b/packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js new file mode 100644 index 00000000..d66dc962 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js @@ -0,0 +1,167 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeIdAndAuditFields } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +function checkExperiment(experiment) { + expect(experiment).to.be.an('object'); + expect(experiment.getId()).to.be.a('string'); + expect(experiment.getCreatedAt()).to.be.a('string'); + expect(experiment.getUpdatedAt()).to.be.a('string'); + expect(experiment.getEndDate()).to.be.a('string'); + expect(experiment.getExpId()).to.be.a('string'); + expect(experiment.getName()).to.be.a('string'); + expect(experiment.getStatus()).to.be.a('string'); + expect(experiment.getStartDate()).to.be.a('string'); + expect(experiment.getType()).to.be.a('string'); + expect(experiment.getUrl()).to.be.a('string'); + expect(experiment.getVariants()).to.be.an('array'); +} + +describe('Experiment IT', async () => { + let sampleData; + let Experiment; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Experiment = dataAccess.Experiment; + }); + + it('gets all experiments for a site', async () => { + const site = sampleData.sites[0]; + + const experiments = await Experiment.allBySiteId(site.getId()); + + expect(experiments).to.be.an('array'); + expect(experiments.length).to.equal(3); + + experiments.forEach((experiment) => { + expect(experiment.getSiteId()).to.equal(site.getId()); + checkExperiment(experiment); + }); + }); + + it('gets all experiments for a site and expId', async () => { + const site = sampleData.sites[0]; + const expId = 'experiment-1'; + + const experiments = await Experiment.allBySiteIdAndExpId(site.getId(), expId); + + expect(experiments).to.be.an('array'); + expect(experiments.length).to.equal(1); + + const experiment = experiments[0]; + expect(experiment.getSiteId()).to.equal(site.getId()); + checkExperiment(experiment); + }); + + it('returns empty array for a site with no experiments', async () => { + const site = sampleData.sites[1]; + + const experiments = await Experiment.allBySiteId(site.getId()); + + expect(experiments).to.be.an('array'); + expect(experiments.length).to.equal(0); + }); + + it('finds one experiment by siteId, expId and url', async () => { + const site = sampleData.sites[0]; + const expId = 'experiment-1'; + const url = 'https://example0.com/page-1'; + + const experiment = await Experiment.findBySiteIdAndExpId(site.getId(), expId, url); + + checkExperiment(experiment); + expect(experiment.getUrl()).to.equal(url); + }); + + it('adds a new experiment to a site', async () => { + const site = sampleData.sites[0]; + const experimentData = { + siteId: site.getId(), + expId: 'experiment-4', + name: 'Experiment 4', + url: 'https://example0.com/page-4', + status: 'ACTIVE', + type: 'full', + startDate: '2024-12-06T08:35:24.125Z', + endDate: '2025-12-06T08:35:24.125Z', + variants: [ + { + label: 'Challenger 1', + name: 'challenger-1', + interactionsCount: 10, + p_value: 'coming soon', + split: 0.8, + url: 'https://example0.com/page-4/variant-1', + views: 100, + metrics: [ + { + selector: '.header .button', + type: 'click', + value: 2, + }, + ], + }, + { + label: 'Challenger 2', + name: 'challenger-2', + interactionsCount: 20, + p_value: 'coming soon', + metrics: [], + split: 0.8, + url: 'https://example0.com/page-4/variant-2', + views: 200, + }, + ], + updatedBy: 'scheduled-experiment-audit', + }; + + const addedExperiment = await Experiment.create(experimentData); + + checkExperiment(addedExperiment); + + expect(sanitizeIdAndAuditFields('Experiment', addedExperiment.toJSON())).to.eql(experimentData); + }); + + it('updates an existing experiment', async () => { + const site = sampleData.sites[0]; + const expId = 'experiment-1'; + const url = 'https://example0.com/page-1'; + const updates = { + name: 'Updated Experiment 1', + url: 'https://example0.com/page-1/updated', + }; + + const experiment = await Experiment.findBySiteIdAndExpIdAndUrl(site.getId(), expId, url); + experiment.setName(updates.name); + experiment.setUrl(updates.url); + + await experiment.save(); + + checkExperiment(experiment); + expect(experiment.getName()).to.equal(updates.name); + expect(experiment.getUrl()).to.equal(updates.url); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/fixtures.js b/packages/spacecat-shared-data-access/test/it/fixtures.js new file mode 100644 index 00000000..34e50988 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/fixtures.js @@ -0,0 +1,84 @@ +/* + * 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-disable no-console */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { spawn } from 'dynamo-db-local'; + +import { sleep } from '../unit/util.js'; + +let dynamoDbLocalProcess = null; + +async function waitForDynamoDBStartup(url, timeout = 20000, interval = 500) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await fetch(url); + if (response.status === 400) { + return; + } + } catch (error) { + // eslint-disable-next-line no-console + console.log('DynamoDB Local not yet started', error.message); + } + // eslint-disable-next-line no-await-in-loop + await sleep(interval); + } + throw new Error('DynamoDB Local did not start within the expected time'); +} + +/** + * This function is called once before any tests are executed. It is used to start + * any services that are required for the tests, such as a local DynamoDB instance. + * See https://mochajs.org/#global-fixtures + * @return {Promise} + */ +export async function mochaGlobalSetup() { + console.log('mochaGlobalSetup'); + + process.env.AWS_REGION = 'local'; + process.env.AWS_ENDPOINT_URL_DYNAMODB = 'http://127.0.0.1:8000'; + process.env.AWS_DEFAULT_REGION = 'local'; + process.env.AWS_ACCESS_KEY_ID = 'dummy'; + process.env.AWS_SECRET_ACCESS_KEY = 'dummy'; + + dynamoDbLocalProcess = spawn({ + detached: true, + stdio: 'inherit', + port: 8000, + sharedDb: true, + }); + + await waitForDynamoDBStartup('http://127.0.0.1:8000'); + + process.on('SIGINT', () => { + if (dynamoDbLocalProcess) { + dynamoDbLocalProcess.kill(); + } + process.exit(); + }); +} + +/** + * This function is called once after all tests are executed. It is used to clean up + * any services that were started in mochaGlobalSetup. + * See: https://mochajs.org/#global-fixtures + * @return {Promise} + */ +export async function mochaGlobalTeardown() { + console.log('mochaGlobalTeardown'); + + dynamoDbLocalProcess.kill(); + dynamoDbLocalProcess = null; +} diff --git a/packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js b/packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js new file mode 100644 index 00000000..d6d2439e --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js @@ -0,0 +1,165 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +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 checkImportJob(importJob) { + expect(importJob).to.be.an('object'); + expect(importJob.getBaseURL()).to.be.a('string'); + expect(importJob.getDuration()).to.be.a('number'); + expect(importJob.getFailedCount()).to.be.a('number'); + expect(importJob.getHasCustomHeaders()).to.be.a('boolean'); + expect(importJob.getHasCustomImportJs()).to.be.a('boolean'); + expect(importJob.getHashedApiKey()).to.be.a('string'); + expect(importJob.getImportQueueId()).to.be.a('string'); + expect(importJob.getInitiatedBy()).to.be.an('object'); + expect(importJob.getRedirectCount()).to.be.an('number'); + expect(importJob.getStartedAt()).to.be.a('string'); + expect(importJob.getStatus()).to.be.a('string'); + expect(importJob.getSuccessCount()).to.be.an('number'); + expect(importJob.getUrlCount()).to.be.an('number'); +} + +describe('ImportJob IT', async () => { + let sampleData; + let ImportJob; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + ImportJob = dataAccess.ImportJob; + }); + + it('adds a new import job', async () => { + const data = { + importQueueId: 'some-queue-id', + hashedApiKey: 'some-hashed-api-key', + baseURL: 'https://example-some.com/cars', + startedAt: '2023-12-15T01:22:05.000Z', + status: 'RUNNING', + initiatedBy: { + apiKeyName: 'K-321', + }, + hasCustomImportJs: false, + hasCustomHeaders: true, + }; + const importJob = await ImportJob.create(data); + + checkImportJob(importJob); + + expect(importJob.getImportQueueId()).to.equal(data.importQueueId); + expect(importJob.getHashedApiKey()).to.equal(data.hashedApiKey); + expect(importJob.getBaseURL()).to.equal(data.baseURL); + expect(importJob.getStartedAt()).to.equal(data.startedAt); + expect(importJob.getStatus()).to.equal(data.status); + expect(importJob.getInitiatedBy()).to.eql(data.initiatedBy); + expect(importJob.getHasCustomImportJs()).to.equal(data.hasCustomImportJs); + expect(importJob.getHasCustomHeaders()).to.equal(data.hasCustomHeaders); + }); + + it('updates an existing import job', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const importJob = await ImportJob.findById(sampleImportJob.getId()); + + const updates = { + status: 'COMPLETE', + endedAt: '2023-11-15T03:49:13.000Z', + successCount: 86, + failedCount: 4, + redirectCount: 10, + urlCount: 100, + duration: 188000, + }; + + await importJob + .setStatus(updates.status) + .setEndedAt(updates.endedAt) + .setSuccessCount(updates.successCount) + .setFailedCount(updates.failedCount) + .setRedirectCount(updates.redirectCount) + .setUrlCount(updates.urlCount) + .setDuration(updates.duration) + .save(); + + const updatedImportJob = await ImportJob.findById(importJob.getId()); + + checkImportJob(updatedImportJob); + + expect(updatedImportJob.getStatus()).to.equal(updates.status); + expect(updatedImportJob.getEndedAt()).to.equal(updates.endedAt); + expect(updatedImportJob.getSuccessCount()).to.equal(updates.successCount); + expect(updatedImportJob.getFailedCount()).to.equal(updates.failedCount); + expect(updatedImportJob.getRedirectCount()).to.equal(updates.redirectCount); + expect(updatedImportJob.getUrlCount()).to.equal(updates.urlCount); + expect(updatedImportJob.getDuration()).to.equal(updates.duration); + }); + + it('finds an import job by its id', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const importJob = await ImportJob.findById(sampleImportJob.getId()); + + checkImportJob(importJob); + expect(importJob.getId()).to.equal(sampleImportJob.getId()); + }); + + it('gets all import jobs by status', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const importJobs = await ImportJob.allByStatus(sampleImportJob.getStatus()); + + expect(importJobs).to.be.an('array'); + importJobs.forEach((importJob) => { + checkImportJob(importJob); + expect(importJob.getStatus()).to.equal(sampleImportJob.getStatus()); + }); + }); + + it('gets all import jobs by date range', async () => { + const importJobs = await ImportJob.allByDateRange( + '2023-11-14T00:00:00.000Z', + '2023-11-16T00:00:00.000Z', + ); + + expect(importJobs).to.be.an('array'); + expect(importJobs.length).to.equal(2); + + importJobs.forEach((importJob) => { + checkImportJob(importJob); + }); + }); + + it('removes an import job', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const importJob = await ImportJob.findById(sampleImportJob.getId()); + + const importUrls = await importJob.getImportUrls(); + + expect(importUrls).to.be.an('array'); + expect(importUrls.length).to.equal(5); + + await importJob.remove(); + + const removedImportJob = await ImportJob.findById(sampleImportJob.getId()); + expect(removedImportJob).to.be.null; + + // todo: verify import urls are removed when base collection is implemented + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js b/packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js new file mode 100644 index 00000000..7a9a2a73 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js @@ -0,0 +1,131 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +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 checkImportUrl(importUrl) { + expect(importUrl).to.be.an('object'); + expect(importUrl.getExpiresAt()).to.be.a('string'); + expect(importUrl.getImportJobId()).to.be.a('string'); + expect(importUrl.getStatus()).to.be.a('string'); + expect(importUrl.getUrl()).to.be.a('string'); +} + +describe('ImportUrl IT', async () => { + let sampleData; + let ImportUrl; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + ImportUrl = dataAccess.ImportUrl; + }); + + it('adds a new import url', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const data = { + importJobId: sampleImportJob.getId(), + url: 'https://example-some.com/cars', + status: 'RUNNING', + initiatedBy: { + apiKeyName: 'K-321', + imsUserId: 'U-123', + imsOrgId: 'O-123', + }, + }; + + const importUrl = await ImportUrl.create(data); + + checkImportUrl(importUrl); + }); + + it('updates an import url', async () => { + const data = { + url: 'https://example-some.com/cars', + status: 'RUNNING', + file: 'some-file', + reason: 'some-reason', + }; + + const importUrl = await ImportUrl.findById(sampleData.importUrls[0].getId()); + await importUrl + .setUrl(data.url) + .setStatus(data.status) + .setFile(data.file) + .setReason(data.reason) + .save(); + + const updatedImportUrl = await ImportUrl.findById(sampleData.importUrls[0].getId()); + + checkImportUrl(updatedImportUrl); + + expect(updatedImportUrl.getStatus()).to.equal(data.status); + expect(updatedImportUrl.getUrl()).to.equal(data.url); + expect(updatedImportUrl.getFile()).to.equal(data.file); + expect(updatedImportUrl.getReason()).to.equal(data.reason); + }); + + it('it gets all import urls by import job id', async () => { + const importJob = sampleData.importJobs[0]; + const importUrls = await ImportUrl.allByImportJobId(importJob.getId()); + + expect(importUrls).to.be.an('array'); + expect(importUrls.length).to.equal(6); + + importUrls.forEach((importUrl) => { + expect(importUrl.getImportJobId()).to.equal(importJob.getId()); + checkImportUrl(importUrl); + }); + }); + + it('it gets all import urls by job id and status', async () => { + const importJob = sampleData.importJobs[0]; + const importUrls = await ImportUrl.allByImportJobIdAndStatus(importJob.getId(), 'RUNNING'); + + expect(importUrls).to.be.an('array'); + expect(importUrls.length).to.equal(2); + + importUrls.forEach((importUrl) => { + expect(importUrl.getImportJobId()).to.equal(importJob.getId()); + expect(importUrl.getStatus()).to.equal('RUNNING'); + checkImportUrl(importUrl); + }); + }); + + it('finds an import url by its id', async () => { + const sampleImportUrl = sampleData.importUrls[0]; + const importUrl = await ImportUrl.findById(sampleImportUrl.getId()); + + checkImportUrl(importUrl); + expect(importUrl.getId()).to.equal(sampleImportUrl.getId()); + }); + + it('removes an import url', async () => { + const sampleImportUrl = sampleData.importUrls[0]; + const importUrl = await ImportUrl.findById(sampleImportUrl.getId()); + + await importUrl.remove(); + + const removedImportUrl = await ImportUrl.findById(sampleImportUrl.getId()); + expect(removedImportUrl).to.be.null; + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/index.test.js b/packages/spacecat-shared-data-access/test/it/index.test.js old mode 100644 new mode 100755 index 09d2ca4a..43fefb3d --- a/packages/spacecat-shared-data-access/test/it/index.test.js +++ b/packages/spacecat-shared-data-access/test/it/index.test.js @@ -13,18 +13,17 @@ /* eslint-env mocha */ /* eslint-disable no-console */ +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import Joi from 'joi'; - -import { isIsoDate } from '@adobe/spacecat-shared-utils'; import { v4 as uuidv4 } from 'uuid'; -import { sleep } from '../unit/util.js'; -import { createDataAccess } from '../../src/service/index.js'; + import { configSchema } from '../../src/models/site/config.js'; import { AUDIT_TYPE_LHS_MOBILE } from '../../src/models/audit.js'; +import { sleep } from '../unit/util.js'; -import generateSampleData from './util/generateSampleData.js'; import { createSiteCandidate, SITE_CANDIDATE_SOURCES, @@ -34,7 +33,8 @@ import { KEY_EVENT_TYPES } from '../../src/models/key-event.js'; import { ConfigurationDto } from '../../src/dto/configuration.js'; import { ImportJobStatus, ImportOptions, ImportUrlStatus } from '../../src/index.js'; import { IMPORT_URL_EXPIRES_IN_DAYS } from '../../src/models/importer/import-url.js'; -import { closeDynamoClients } from './util/db.js'; +import { getDataAccess } from './util/db.js'; +import { seedDatabase } from './util/seed.js'; use(chaiAsPromised); @@ -96,74 +96,20 @@ function checkSiteTopPage(siteTopPage) { expect(isIsoDate(siteTopPage.getImportedAt())).to.be.true; } -export const TEST_DA_CONFIG = { - tableNameAudits: 'spacecat-services-audits', - tableNameKeyEvents: 'spacecat-services-key-events', - tableNameLatestAudits: 'spacecat-services-latest-audits', - tableNameOrganizations: 'spacecat-services-organizations', - tableNameSites: 'spacecat-services-sites', - tableNameSiteCandidates: 'spacecat-services-site-candidates', - tableNameConfigurations: 'spacecat-services-configurations', - tableNameSiteTopPages: 'spacecat-services-site-top-pages', - tableNameExperiments: 'spacecat-services-experiments', - tableNameApiKeys: 'spacecat-services-api-keys', - tableNameImportJobs: 'spacecat-services-import-jobs', - tableNameImportUrls: 'spacecat-services-import-urls', - tableNameSpacecatData: 'spacecat-data', - indexNameAllSites: 'spacecat-services-all-sites', - indexNameAllKeyEventsBySiteId: 'spacecat-services-key-events-by-site-id', - indexNameAllSitesOrganizations: 'spacecat-services-all-sites-organizations', - indexNameAllOrganizations: 'spacecat-services-all-organizations', - indexNameAllOrganizationsByImsOrgId: 'spacecat-services-all-organizations-by-ims-org-id', - indexNameAllSitesByDeliveryType: 'spacecat-services-all-sites-by-delivery-type', - indexNameAllLatestAuditScores: 'spacecat-services-all-latest-audit-scores', - indexNameAllImportJobsByStatus: 'spacecat-services-all-import-jobs-by-status', - indexNameImportUrlsByJobIdAndStatus: 'spacecat-services-all-import-urls-by-job-id-and-status', - indexNameAllImportJobsByDateRange: 'spacecat-services-all-import-jobs-by-date-range', - indexNameApiKeyByHashedApiKey: 'spacecat-services-api-key-by-hashed-api-key', - indexNameApiKeyByImsUserIdAndImsOrgId: 'spacecat-services-api-key-by-ims-user-id-and-ims-org-id', - pkAllSites: 'ALL_SITES', - pkAllOrganizations: 'ALL_ORGANIZATIONS', - pkAllLatestAudits: 'ALL_LATEST_AUDITS', - pkAllConfigurations: 'ALL_CONFIGURATIONS', - pkAllImportJobs: 'ALL_IMPORT_JOBS', -}; +const NUMBER_OF_SITES = 10; +const NUMBER_OF_ORGANIZATIONS = 3; +const NUMBER_OF_AUDITS_PER_TYPE_AND_SITE = 5; +const NUMBER_OF_TOP_PAGES_PER_SITE = 5; +const NUMBER_OF_KEY_EVENTS_PER_SITE = 10; +const NUMBER_OF_EXPERIMENTS = 3; // eslint-disable-next-line func-names -describe('Legacy Data Model IT', function () { - this.timeout(30000); - +describe('Legacy Data Model IT', async () => { let dataAccess; - const NUMBER_OF_SITES = 10; - const NUMBER_OF_SITES_CANDIDATES = 10; - const NUMBER_OF_ORGANIZATIONS = 3; - const NUMBER_OF_AUDITS_PER_TYPE_AND_SITE = 3; - const NUMBER_OF_TOP_PAGES_PER_SITE = 5; - const NUMBER_OF_TOP_PAGES_FOR_SITE = NUMBER_OF_SITES * NUMBER_OF_TOP_PAGES_PER_SITE; - const NUMBER_OF_KEY_EVENTS_PER_SITE = 10; - const NUMBER_OF_EXPERIMENTS = 3; - before(async () => { - try { - await generateSampleData( - TEST_DA_CONFIG, - NUMBER_OF_ORGANIZATIONS, - NUMBER_OF_SITES, - NUMBER_OF_SITES_CANDIDATES, - NUMBER_OF_AUDITS_PER_TYPE_AND_SITE, - NUMBER_OF_TOP_PAGES_FOR_SITE, - NUMBER_OF_KEY_EVENTS_PER_SITE, - ); - } catch (e) { - console.error('Error generating sample data', e); - } - - dataAccess = createDataAccess(TEST_DA_CONFIG, console); - }); - - after(async () => { - await closeDynamoClients(); + await seedDatabase(); + dataAccess = getDataAccess(); }); it('get all key events for a site', async () => { diff --git a/packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js b/packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js new file mode 100644 index 00000000..db1180b0 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js @@ -0,0 +1,99 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +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 checkKeyEvent(keyEvent) { + expect(keyEvent).to.be.an('object'); + expect(keyEvent.getId()).to.be.a('string'); + expect(keyEvent.getCreatedAt()).to.be.a('string'); + expect(keyEvent.getUpdatedAt()).to.be.a('string'); + expect(keyEvent.getSiteId()).to.be.a('string'); + expect(keyEvent.getName()).to.be.a('string'); + expect(keyEvent.getType()).to.be.a('string'); + expect(keyEvent.getTime()).to.be.a('string'); +} + +describe('KeyEvent IT', async () => { + let sampleData; + let KeyEvent; + let Site; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + KeyEvent = dataAccess.KeyEvent; + Site = dataAccess.Site; + }); + + it('gets all key events for a site', async () => { + const site = sampleData.sites[1]; + + const keyEvents = await KeyEvent.allBySiteId(site.getId()); + + expect(keyEvents).to.be.an('array'); + expect(keyEvents.length).to.equal(10); + + keyEvents.forEach((keyEvent) => { + expect(keyEvent.getSiteId()).to.equal(site.getId()); + checkKeyEvent(keyEvent); + }); + }); + + it('adds a new key event for a site', async () => { + const site = sampleData.sites[1]; + const keyEvent = await KeyEvent.create({ + siteId: site.getId(), + name: 'keyEventName', + type: 'PERFORMANCE', + time: '2024-12-06T08:35:24.125Z', + }); + + checkKeyEvent(keyEvent); + + expect(keyEvent.getSiteId()).to.equal(site.getId()); + + const siteWithKeyEvent = await Site.findById(site.getId()); + + const keyEvents = await siteWithKeyEvent.getKeyEvents(); + expect(keyEvents).to.be.an('array'); + expect(keyEvents.length).to.equal(11); + + const lastKeyEvent = keyEvents[0]; + checkKeyEvent(lastKeyEvent); + expect(lastKeyEvent.getId()).to.equal(keyEvent.getId()); + }); + + it('removes a key event', async () => { + const site = sampleData.sites[1]; + const keyEvents = await site.getKeyEvents(); + const keyEvent = keyEvents[0]; + + await keyEvent.remove(); + + const siteWithKeyEvent = await Site.findById(site.getId()); + + const updatedKeyEvents = await siteWithKeyEvent.getKeyEvents(); + expect(updatedKeyEvents).to.be.an('array'); + expect(updatedKeyEvents.length).to.equal(keyEvents.length - 1); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js new file mode 100644 index 00000000..2a50d998 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js @@ -0,0 +1,320 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { v4 as uuid, validate as uuidValidate } from 'uuid'; + +import { ValidationError } from '../../../src/index.js'; + +import fixtures from '../../fixtures/index.fixtures.js'; +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +describe('Opportunity IT', async () => { + const { siteId } = fixtures.sites[0]; + + let sampleData; + + let Opportunity; + let Suggestion; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Opportunity = dataAccess.Opportunity; + Suggestion = dataAccess.Suggestion; + }); + + it('finds one opportunity by id', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); + + expect(opportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.opportunities[0].toJSON()), + ); + + const suggestions = await opportunity.getSuggestions(); + expect(suggestions).to.be.an('array').with.length(3); + + const parentOpportunity = await suggestions[0].getOpportunity(); + expect(parentOpportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.opportunities[0].toJSON()), + ); + }); + + it('finds all opportunities by siteId and status', async () => { + const opportunities = await Opportunity.allBySiteIdAndStatus(siteId, 'NEW'); + + expect(opportunities).to.be.an('array').with.length(2); + }); + + it('partially updates one opportunity by id', async () => { + // retrieve the opportunity by ID + const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); + expect(opportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.opportunities[0].toJSON()), + ); + + // apply updates + const updates = { + runbook: 'https://example-updated.com', + status: 'IN_PROGRESS', + }; + + opportunity + .setRunbook(updates.runbook) + .setStatus(updates.status); + + // opportunity.setAuditId('invalid-audit-id'); + + await opportunity.save(); + + expect(opportunity.getRunbook()).to.equal(updates.runbook); + expect(opportunity.getStatus()).to.equal(updates.status); + + const updated = sanitizeTimestamps(opportunity.toJSON()); + delete updated.runbook; + delete updated.status; + + const original = sanitizeTimestamps(sampleData.opportunities[0].toJSON()); + delete original.runbook; + delete original.status; + + expect(updated).to.eql(original); + + const storedOpportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); + expect(storedOpportunity.getRunbook()).to.equal(updates.runbook); + expect(storedOpportunity.getStatus()).to.equal(updates.status); + + const storedWithoutUpdatedAt = { ...storedOpportunity.toJSON() }; + const inMemoryWithoutUpdatedAt = { ...opportunity.toJSON() }; + delete storedWithoutUpdatedAt.updatedAt; + delete inMemoryWithoutUpdatedAt.updatedAt; + + expect(storedWithoutUpdatedAt).to.eql(inMemoryWithoutUpdatedAt); + }); + + it('finds all opportunities by siteId', async () => { + const opportunities = await Opportunity.allBySiteId(siteId); + + expect(opportunities).to.be.an('array').with.length(3); + }); + + it('creates a new opportunity', async () => { + const data = { + siteId, + auditId: uuid(), + title: 'New Opportunity', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-backlinks', + origin: 'AI', + status: 'NEW', + guidance: { foo: 'bar' }, + data: { brokenLinks: ['https://example.com'] }, + }; + + const opportunity = await Opportunity.create(data); + + expect(opportunity).to.be.an('object'); + + expect(uuidValidate(opportunity.getId())).to.be.true; + expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; + expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; + + const record = opportunity.toJSON(); + delete record.opportunityId; + delete record.createdAt; + delete record.updatedAt; + expect(record).to.eql(data); + }); + + it('creates a new opportunity without auditId', async () => { + const data = { + siteId, + title: 'New Opportunity', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-backlinks', + origin: 'AI', + status: 'NEW', + guidance: { foo: 'bar' }, + data: { brokenLinks: ['https://example.com'] }, + }; + + const opportunity = await Opportunity.create(data); + + expect(opportunity).to.be.an('object'); + + expect(uuidValidate(opportunity.getId())).to.be.true; + expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; + expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; + + const record = opportunity.toJSON(); + delete record.opportunityId; + delete record.createdAt; + delete record.updatedAt; + expect(record).to.eql(data); + + expect(opportunity.getAuditId()).to.be.undefined; + await expect(opportunity.getAudit()).to.eventually.be.equal(null); + }); + + it('removes an opportunity', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); + const suggestions = await opportunity.getSuggestions(); + + expect(suggestions).to.be.an('array').with.length(3); + + await opportunity.remove(); + + const notFound = await Opportunity.findById(sampleData.opportunities[0].getId()); + await expect(notFound).to.be.null; + + // make sure dependent suggestions are removed as well + await Promise.all(suggestions.map(async (suggestion) => { + const notFoundSuggestion = await Suggestion.findById(suggestion.getId()); + await expect(notFoundSuggestion).to.be.null; + })); + }); + + it('creates many opportunities', async () => { + const data = [ + { + siteId, + auditId: uuid(), + title: 'New Opportunity 1', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-backlinks', + origin: 'AI', + status: 'NEW', + data: { brokenLinks: ['https://example.com'] }, + }, + { + siteId, + auditId: uuid(), + title: 'New Opportunity 2', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-internal-links', + origin: 'AI', + status: 'NEW', + data: { brokenInternalLinks: ['https://example.com'] }, + }, + ]; + + const opportunities = await Opportunity.createMany(data); + + expect(opportunities).to.be.an('object'); + expect(opportunities.createdItems).to.be.an('array').with.length(2); + expect(opportunities.errorItems).to.be.an('array').with.length(0); + + opportunities.createdItems.forEach((opportunity, index) => { + expect(opportunity).to.be.an('object'); + + expect(uuidValidate(opportunity.getId())).to.be.true; + expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; + expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; + + expect( + sanitizeIdAndAuditFields('Opportunity', opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(data[index]), + ); + }); + }); + + it('fails to create many opportunities with invalid data', async () => { + const data = [ + { + siteId, + auditId: uuid(), + title: 'New Opportunity 1', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-backlinks', + origin: 'AI', + status: 'NEW', + data: { brokenLinks: ['https://example.com'] }, + }, + { + siteId, + auditId: uuid(), + title: 'New Opportunity 2', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-internal-links', + origin: 'AI', + status: 'NEW', + data: { brokenInternalLinks: ['https://example.com'] }, + }, + { + siteId, + auditId: uuid(), + title: 'New Opportunity 3', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-internal-links', + origin: 'AI', + status: 'NEW', + data: { brokenInternalLinks: ['https://example.com'] }, + }, + ]; + + data[2].title = null; + + const result = await Opportunity.createMany(data); + + expect(result).to.be.an('object'); + expect(result).to.have.property('createdItems'); + expect(result).to.have.property('errorItems'); + + expect(result.createdItems).to.be.an('array').with.length(2); + expect(result.errorItems).to.be.an('array').with.length(1); + expect(result.errorItems[0].item).to.eql(data[2]); + expect(result.errorItems[0].error).to.be.an.instanceOf(ValidationError); + + const [opportunity1, opportunity2] = result.createdItems; + + const record1 = opportunity1.toJSON(); + delete record1.opportunityId; + delete record1.createdAt; + delete record1.updatedAt; + + const record2 = opportunity2.toJSON(); + delete record2.opportunityId; + delete record2.createdAt; + delete record2.updatedAt; + + expect(record1).to.eql(data[0]); + expect(record2).to.eql(data[1]); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/organization/organization.test.js b/packages/spacecat-shared-data-access/test/it/organization/organization.test.js new file mode 100644 index 00000000..290d5abb --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/organization/organization.test.js @@ -0,0 +1,143 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +describe('Organization IT', async () => { + let sampleData; + let Organization; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Organization = dataAccess.Organization; + }); + + it('gets all organizations', async () => { + const organizations = await Organization.all(); + organizations.reverse(); // sort key is descending by default + + expect(organizations).to.be.an('array'); + expect(organizations.length).to.equal(sampleData.organizations.length); + for (let i = 0; i < organizations.length; i += 1) { + const org = sanitizeTimestamps(organizations[i].toJSON()); + const sampleOrg = sanitizeTimestamps(sampleData.organizations[i].toJSON()); + + expect(org).to.eql(sampleOrg); + } + }); + + it('gets an organization by id', async () => { + const sampleOrganization = sampleData.organizations[0]; + const organization = await Organization.findById(sampleOrganization.getId()); + + expect(organization).to.be.an('object'); + expect( + sanitizeTimestamps(organization.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleOrganization.toJSON()), + ); + }); + + it('gets an organization by IMS org id', async () => { + const sampleOrganization = sampleData.organizations[0]; + const organization = await Organization.findByImsOrgId(sampleOrganization.getImsOrgId()); + + expect(organization).to.be.an('object'); + expect( + sanitizeTimestamps(organization.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleOrganization.toJSON()), + ); + }); + + it('adds a new organization', async () => { + const data = { + name: 'New Organization', + imsOrgId: 'newOrgId', + config: { + some: 'config', + }, + fulfillableItems: { + some: 'items', + }, + }; + + const organization = await Organization.create(data); + + expect(organization).to.be.an('object'); + + expect( + sanitizeIdAndAuditFields('Organization', organization.toJSON()), + ).to.eql(data); + }); + + it('updates an organization', async () => { + const organization = await Organization.findById(sampleData.organizations[0].getId()); + + const data = { + name: 'Updated Organization', + imsOrgId: 'updatedOrgId', + config: { + some: 'updated', + }, + fulfillableItems: { + some: 'updated', + }, + }; + + const expectedOrganization = { + ...organization.toJSON(), + ...data, + }; + + organization.setName(data.name); + organization.setImsOrgId(data.imsOrgId); + organization.setConfig(data.config); + organization.setFulfillableItems(data.fulfillableItems); + + await organization.save(); + + const updatedOrganization = await Organization.findById(organization.getId()); + expect(updatedOrganization.getId()).to.equal(organization.getId()); + expect(updatedOrganization.record.createdAt).to.equal(organization.record.createdAt); + expect(updatedOrganization.record.updatedAt).to.not.equal(organization.record.updatedAt); + expect( + sanitizeIdAndAuditFields('Organization', updatedOrganization.toJSON()), + ).to.eql( + sanitizeIdAndAuditFields('Organization', expectedOrganization), + ); + }); + + it('removes an organization', async () => { + const organization = await Organization.findById(sampleData.organizations[0].getId()); + + await organization.remove(); + + const notFound = await Organization.findById(sampleData.organizations[0].getId()); + expect(notFound).to.be.null; + + // todo: add test for removing an organization with associated sites once + // that functionality is implemented + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/site-candidate/site-candidate.test.js b/packages/spacecat-shared-data-access/test/it/site-candidate/site-candidate.test.js new file mode 100644 index 00000000..bc137520 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/site-candidate/site-candidate.test.js @@ -0,0 +1,105 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +function checkSiteCandidate(siteCandidate) { + expect(siteCandidate).to.be.an('object'); + expect(siteCandidate.getBaseURL()).to.be.a('string'); + expect(siteCandidate.getCreatedAt()).to.be.a('string'); + expect(siteCandidate.getSource()).to.be.a('string'); + expect(siteCandidate.getStatus()).to.be.a('string'); + expect(siteCandidate.getUpdatedAt()).to.be.a('string'); +} + +describe('SiteCandidate IT', async () => { + let sampleData; + let SiteCandidate; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + SiteCandidate = dataAccess.SiteCandidate; + }); + + it('finds one site candidate by base url', async () => { + const sampleSiteCandidate = sampleData.siteCandidates[6]; + + const siteCandidate = await SiteCandidate.findByBaseURL(sampleSiteCandidate.getBaseURL()); + + checkSiteCandidate(siteCandidate); + + expect( + sanitizeTimestamps(siteCandidate.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleSiteCandidate.toJSON()), + ); + }); + + it('returns null when site candidate is not found by base url', async () => { + const siteCandidate = await SiteCandidate.findByBaseURL('https://www.example.com'); + + expect(siteCandidate).to.be.null; + }); + + it('adds a new site candidate', async () => { + const data = { + baseURL: 'https://www.example.com', + source: 'RUM', + status: 'PENDING', + }; + const siteCandidate = await SiteCandidate.create(data); + + checkSiteCandidate(siteCandidate); + + expect(siteCandidate.getBaseURL()).to.equal(data.baseURL); + expect(siteCandidate.getSource()).to.equal(data.source); + expect(siteCandidate.getStatus()).to.equal(data.status); + }); + + it('updates a site candidate', async () => { + const sampleSiteCandidate = sampleData.siteCandidates[0]; + const updates = { + baseURL: 'https://www.example-updated.com', + status: 'APPROVED', + updatedBy: 'some-user', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + }; + + const siteCandidate = await SiteCandidate.findByBaseURL(sampleSiteCandidate.getBaseURL()); + + siteCandidate.setBaseURL(updates.baseURL); + siteCandidate.setStatus(updates.status); + siteCandidate.setUpdatedBy(updates.updatedBy); + siteCandidate.setSiteId(updates.siteId); + + await siteCandidate.save(); + + checkSiteCandidate(siteCandidate); + + expect(siteCandidate.getBaseURL()).to.equal(updates.baseURL); + expect(siteCandidate.getStatus()).to.equal(updates.status); + expect(siteCandidate.getUpdatedBy()).to.equal(updates.updatedBy); + expect(siteCandidate.getSiteId()).to.equal(updates.siteId); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/site-top-page/site-top-page.test.js b/packages/spacecat-shared-data-access/test/it/site-top-page/site-top-page.test.js new file mode 100755 index 00000000..1de3f36b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/site-top-page/site-top-page.test.js @@ -0,0 +1,172 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +function checkSiteTopPage(siteTopPage) { + expect(siteTopPage).to.be.an('object'); + expect(siteTopPage.getId()).to.be.a('string'); + expect(siteTopPage.getSiteId()).to.be.a('string'); + expect(siteTopPage.getUrl()).to.be.a('string'); + expect(siteTopPage.getTraffic()).to.be.a('number'); + expect(siteTopPage.getSource()).to.be.a('string'); + expect(siteTopPage.getTopKeyword()).to.be.a('string'); + expect(siteTopPage.getGeo()).to.be.a('string'); + expect(siteTopPage.getImportedAt()).to.be.a('string'); +} + +describe('SiteTopPage IT', async () => { + let sampleData; + let SiteTopPage; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + SiteTopPage = dataAccess.SiteTopPage; + }); + + it('finds one site top page by id', async () => { + const siteTopPage = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + + expect(siteTopPage).to.be.an('object'); + expect( + sanitizeTimestamps(siteTopPage.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.siteTopPages[0].toJSON()), + ); + }); + + it('gets all site top pages for a site', async () => { + const site = sampleData.sites[0]; + + const siteTopPages = await SiteTopPage.allBySiteId(site.getId()); + + expect(siteTopPages).to.be.an('array'); + expect(siteTopPages.length).to.equal(5); + + siteTopPages.forEach((siteTopPage) => { + checkSiteTopPage(siteTopPage); + expect(siteTopPage.getSiteId()).to.equal(site.getId()); + }); + }); + + it('gets all top pages for a site from a specific source and geo in descending traffic order', async () => { + const site = sampleData.sites[0]; + const source = 'ahrefs'; + const geo = 'global'; + + const siteTopPages = await SiteTopPage.allBySiteIdAndSourceAndGeo( + site.getId(), + source, + geo, + { order: 'desc' }, + ); + + expect(siteTopPages).to.be.an('array'); + expect(siteTopPages.length).to.equal(5); + + siteTopPages.forEach((siteTopPage) => { + checkSiteTopPage(siteTopPage); + expect(siteTopPage.getSiteId()).to.equal(site.getId()); + expect(siteTopPage.getSource()).to.equal(source); + expect(siteTopPage.getGeo()).to.equal(geo); + }); + + for (let i = 1; i < siteTopPages.length; i += 1) { + expect(siteTopPages[i - 1].getTraffic()).to.be.at.least(siteTopPages[i].getTraffic()); + } + }); + + it('creates a site top page', async () => { + const data = { + siteId: sampleData.sites[0].getId(), + url: 'https://www.example.com', + traffic: 100, + source: 'google', + topKeyword: 'example', + geo: 'US', + importedAt: '2024-12-06T08:35:24.125Z', + }; + const siteTopPage = await SiteTopPage.create(data); + + checkSiteTopPage(siteTopPage); + + expect(siteTopPage.getSiteId()).to.equal(data.siteId); + expect(siteTopPage.getUrl()).to.equal(data.url); + expect(siteTopPage.getTraffic()).to.equal(data.traffic); + expect(siteTopPage.getSource()).to.equal(data.source); + expect(siteTopPage.getTopKeyword()).to.equal(data.topKeyword); + expect(siteTopPage.getGeo()).to.equal(data.geo); + expect(siteTopPage.getImportedAt()).to.equal(data.importedAt); + }); + + it('updates a site top page', async () => { + const siteTopPage = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + + const updates = { + traffic: 200, + source: 'bing', + topKeyword: 'example2', + geo: 'CA', + importedAt: '2024-12-07T08:35:24.125Z', + }; + + siteTopPage + .setTraffic(updates.traffic) + .setSource(updates.source) + .setTopKeyword(updates.topKeyword) + .setGeo(updates.geo) + .setImportedAt(updates.importedAt); + + await siteTopPage.save(); + + const updatedSiteTopPage = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + + checkSiteTopPage(updatedSiteTopPage); + + expect(updatedSiteTopPage.getTraffic()).to.equal(updates.traffic); + expect(updatedSiteTopPage.getSource()).to.equal(updates.source); + expect(updatedSiteTopPage.getTopKeyword()).to.equal(updates.topKeyword); + expect(updatedSiteTopPage.getGeo()).to.equal(updates.geo); + expect(updatedSiteTopPage.getImportedAt()).to.equal(updates.importedAt); + }); + + it('removes a site top page', async () => { + const siteTopPage = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + + await siteTopPage.remove(); + + const notFound = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + expect(notFound).to.equal(null); + }); + + it('removes all site top pages for a site', async () => { + const site = sampleData.sites[0]; + + await SiteTopPage.removeForSiteId(site.getId()); + + const siteTopPages = await SiteTopPage.allBySiteId(site.getId()); + expect(siteTopPages).to.be.an('array'); + expect(siteTopPages.length).to.equal(0); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/site/site.test.js b/packages/spacecat-shared-data-access/test/it/site/site.test.js new file mode 100644 index 00000000..a2431ac8 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/site/site.test.js @@ -0,0 +1,281 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { sanitizeTimestamps } from '../../../src/v2/util/util.js'; +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +async function checkSite(site) { + expect(site).to.be.an('object'); + expect(site.getId()).to.be.a('string'); + expect(site.getBaseURL()).to.be.a('string'); + expect(site.getDeliveryType()).to.be.a('string'); + expect(site.getGitHubURL()).to.be.a('string'); + expect(site.getHlxConfig()).to.be.an('object'); + expect(site.getOrganizationId()).to.be.a('string'); + expect(isIsoDate(site.getCreatedAt())).to.be.true; + expect(isIsoDate(site.getUpdatedAt())).to.be.true; + + const audits = await site.getAudits(); + expect(audits).to.be.an('array'); + expect(site.getIsLive()).to.be.a('boolean'); + expect(isIsoDate(site.getIsLiveToggledAt())).to.be.true; +} + +describe('Site IT', async () => { + let sampleData; + let Site; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Site = dataAccess.Site; + }); + + it('gets all sites', async () => { + let sites = await Site.all(); + + expect(sites).to.be.an('array'); + expect(sites.length).to.equal(10); + + sites = sites.sort((a, b) => a.getBaseURL().localeCompare(b.getBaseURL())); + + for (let i = 0; i < sites.length; i += 1) { /* eslint-disable no-await-in-loop */ + await checkSite(sites[i]); + } + }); + + it('gets all sites to audit (only id attributes returned)', async () => { + const siteIds = await Site.allSitesToAudit(); + + expect(siteIds).to.be.an('array'); + expect(siteIds.length).to.equal(10); + + const ids = sampleData.sites.reverse().map((site) => site.getId()); + + expect(siteIds).to.eql(ids); + }); + + it('gets all sites by organization id', async () => { + const organizationId = sampleData.organizations[0].getId(); + const sites = await Site.allByOrganizationId(organizationId); + + expect(sites).to.be.an('array'); + expect(sites.length).to.equal(4); + + for (let i = 0; i < sites.length; i += 1) { /* eslint-disable no-await-in-loop */ + const site = sites[i]; + + await checkSite(site); + + const organization = await site.getOrganization(); + + expect(site.getOrganizationId()).to.equal(organizationId); + expect(organization).to.be.an('object'); + expect( + sanitizeTimestamps(organization.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.organizations[0].toJSON()), + ); + } + }); + + it('gets all sites by delivery type', async () => { + const deliveryType = 'aem_edge'; + const sites = await Site.allByDeliveryType(deliveryType); + + expect(sites).to.be.an('array'); + expect(sites.length).to.equal(5); + + for (let i = 0; i < sites.length; i += 1) { + const site = sites[i]; + // eslint-disable-next-line no-await-in-loop + await checkSite(site); + expect(site.getDeliveryType()).to.equal(deliveryType); + } + }); + + it('gets a site by baseURL', async () => { + const site = await Site.findByBaseURL(sampleData.sites[0].getBaseURL()); + + await checkSite(site); + + expect(site.getBaseURL()).to.equal(sampleData.sites[0].getBaseURL()); + }); + + it('gets a site by id', async () => { + const site = await Site.findById(sampleData.sites[0].getId()); + + await checkSite(site); + + expect(site.getId()).to.equal(sampleData.sites[0].getId()); + }); + + it('adds a new site', async () => { + const newSiteData = { + baseURL: 'https://newexample.com', + gitHubURL: 'https://github.com/some-org/test-repo', + hlxConfig: { + cdnProdHost: 'www.another-example.com', + code: { + owner: 'another-owner', + repo: 'another-repo', + source: { + type: 'github', + url: 'https://github.com/another-owner/another-repo', + }, + }, + content: { + contentBusId: '1234', + source: { + type: 'onedrive', + url: 'https://another-owner.sharepoint.com/:f:/r/sites/SomeFolder/Shared%20Documents/another-site/www', + }, + }, + hlxVersion: 5, + }, + organizationId: sampleData.organizations[0].getId(), + isLive: true, + isLiveToggledAt: '2024-12-06T08:35:24.125Z', + audits: [], + config: { + handlers: { + 'lhs-mobile': { + excludedURLs: ['https://example.com/excluded'], + }, + }, + }, + }; + + const newSite = await Site.create(newSiteData); + await checkSite(newSite); + + expect(newSite.getBaseURL()).to.equal(newSiteData.baseURL); + }); + + it('updates a site', async () => { + const site = await Site.findById(sampleData.sites[0].getId()); + const updates = { + baseURL: 'https://updated-example.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://updated-github.com', + isLive: false, + organizationId: sampleData.organizations[1].getId(), + hlxConfig: { + cdnProdHost: 'www.another-example.com', + code: { + owner: 'another-owner', + repo: 'another-repo', + source: { + type: 'github', + url: 'https://github.com/another-owner/another-repo', + }, + }, + content: { + contentBusId: '1234', + source: { + type: 'onedrive', + url: 'https://another-owner.sharepoint.com/:f:/r/sites/SomeFolder/Shared%20Documents/another-site/www', + }, + }, + hlxVersion: 5, + }, + }; + + site.setBaseURL(updates.baseURL); + site.setDeliveryType(updates.deliveryType); + site.setGitHubURL(updates.gitHubURL); + site.setHlxConfig(updates.hlxConfig); + site.setIsLive(updates.isLive); + site.setOrganizationId(updates.organizationId); + + await site.save(); + + const updatedSite = await Site.findById(site.getId()); + + await checkSite(updatedSite); + + expect(updatedSite.getBaseURL()).to.equal(updates.baseURL); + expect(updatedSite.getDeliveryType()).to.equal(updates.deliveryType); + expect(updatedSite.getGitHubURL()).to.equal(updates.gitHubURL); + expect(updatedSite.getIsLive()).to.equal(updates.isLive); + expect(updatedSite.getOrganizationId()).to.equal(updates.organizationId); + }); + + it('removes a site', async () => { + const site = await Site.findById(sampleData.sites[0].getId()); + + await site.remove(); + + const notFound = await Site.findById(sampleData.sites[0].getId()); + expect(notFound).to.be.null; + }); + + it('gets all audits for a site', async () => { + const site = await Site.findById(sampleData.sites[1].getId()); + const audits = await site.getAudits(); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(10); + + for (let i = 0; i < audits.length; i += 1) { + const audit = audits[i]; + + expect(audit.getId()).to.be.a('string'); + expect(audit.getSiteId()).to.equal(site.getId()); + } + }); + + it('gets all audits for a site by type', async () => { + const site = await Site.findById(sampleData.sites[1].getId()); + const audits = await site.getAuditsByAuditType('cwv'); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(5); + + for (let i = 0; i < audits.length; i += 1) { + const audit = audits[i]; + + expect(audit.getId()).to.be.a('string'); + expect(audit.getSiteId()).to.equal(site.getId()); + expect(audit.getAuditType()).to.equal('cwv'); + } + }); + + it('gets all audits for a site by type and auditAt', async () => { + const site = await Site.findById(sampleData.sites[1].getId()); + const audits = await site.getAuditsByAuditTypeAndAuditedAt('cwv', '2024-12-03T08:00:55.754Z'); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(5); + + for (let i = 0; i < audits.length; i += 1) { + const audit = audits[i]; + + expect(audit.getId()).to.be.a('string'); + expect(audit.getSiteId()).to.equal(site.getId()); + expect(audit.getAuditType()).to.equal('cwv'); + expect(audit.getAuditedAt()).to.equal('2024-12-03T08:00:55.754Z'); + } + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js b/packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js new file mode 100644 index 00000000..57ef6ceb --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js @@ -0,0 +1,248 @@ +/* + * 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 */ +/* eslint-disable no-console */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { validate as uuidValidate } from 'uuid'; + +import { ValidationError } from '../../../src/index.js'; +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +describe('Suggestion IT', async () => { + let sampleData; + let Suggestion; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Suggestion = dataAccess.Suggestion; + }); + + it('finds one suggestion by id', async () => { + const sampleSuggestion = sampleData.suggestions[6]; + + const suggestion = await Suggestion.findById(sampleSuggestion.getId()); + + expect(suggestion).to.be.an('object'); + expect( + sanitizeTimestamps(suggestion.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleSuggestion.toJSON()), + ); + + const opportunity = await suggestion.getOpportunity(); + expect(opportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.opportunities[2].toJSON()), + ); + }); + + it('resolves associations for a suggestion', async () => { + const sampleSuggestion = sampleData.suggestions[6]; + + const suggestion = await Suggestion.findById(sampleSuggestion.getId(), { resolve: true }); + + const opportunity = await suggestion.getOpportunity(); + expect(opportunity).to.be.an('object'); + expect(opportunity.getId()).to.equal(suggestion.getOpportunityId()); + expect(opportunity.getId()).to.equal(sampleData.opportunities[2].getId()); + + const site = await opportunity.getSite(); + expect(site).to.be.an('object'); + expect(site.getId()).to.equal(opportunity.getSiteId()); + expect(site.getId()).to.equal(sampleData.sites[0].getId()); + + const organization = await site.getOrganization(); + expect(organization).to.be.an('object'); + expect(organization.getId()).to.equal(site.getOrganizationId()); + expect(organization.getId()).to.equal(sampleData.organizations[0].getId()); + }); + + it('gets all suggestions by opportunityId', async () => { + const sampleOpportunity = sampleData.opportunities[0]; + const suggestions = await Suggestion.allByOpportunityId(sampleOpportunity.getId()); + + expect(suggestions).to.be.an('array').with.length(3); + + suggestions.forEach((suggestion) => { + expect(suggestion.getOpportunityId()).to.equal(sampleOpportunity.getId()); + }); + + const opportunity = await suggestions[0].getOpportunity(); + expect(opportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleOpportunity.toJSON()), + ); + + const suggestionsFromOpportunity = await opportunity.getSuggestions(); + expect(suggestionsFromOpportunity).to.be.an('array').with.length(3); + suggestionsFromOpportunity.forEach((suggestion) => { + expect(suggestion.getOpportunityId()).to.equal(sampleOpportunity.getId()); + }); + }); + + it('gets all suggestions by opportunityId and status', async () => { + const suggestions = await Suggestion.allByOpportunityIdAndStatus( + sampleData.opportunities[0].getId(), + 'NEW', + ); + + expect(suggestions).to.be.an('array').with.length(2); + + suggestions.forEach((suggestion) => { + expect(suggestion.getOpportunityId()).to.equal(sampleData.opportunities[0].getId()); + expect(suggestion.getStatus()).to.equal('NEW'); + }); + }); + + it('updates one suggestion by id', async () => { + // retrieve the suggestion by ID + const suggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); + expect(suggestion).to.be.an('object'); + expect( + sanitizeTimestamps(suggestion.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.suggestions[0].toJSON()), + ); + + // apply updates + const updates = { + status: 'APPROVED', + }; + + await suggestion + .setStatus(updates.status) + .save(); + + // validate in-memory updates + expect(suggestion.getStatus()).to.equal(updates.status); + + const original = sanitizeTimestamps(sampleData.suggestions[0].toJSON()); + delete original.status; + const updated = sanitizeTimestamps(suggestion.toJSON()); + delete updated.status; + + expect(updated).to.eql(original); + + // validate persistence of updates + const storedSuggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); + expect(storedSuggestion.getStatus()).to.equal(updates.status); + + // validate timestamps or audit logs + expect(new Date(storedSuggestion.toJSON().updatedAt)).to.be.greaterThan( + new Date(sampleData.suggestions[0].toJSON().updatedAt), + ); + + // validate persisted record matches in-memory state + const storedWithoutUpdatedAt = { ...storedSuggestion.toJSON() }; + const inMemoryWithoutUpdatedAt = { ...suggestion.toJSON() }; + delete storedWithoutUpdatedAt.updatedAt; + delete inMemoryWithoutUpdatedAt.updatedAt; + + expect(storedWithoutUpdatedAt).to.eql(inMemoryWithoutUpdatedAt); + }); + + it('adds many suggestions to an opportunity', async () => { + const opportunity = sampleData.opportunities[0]; + const data = [ + { + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + data: { foo: 'bar' }, + }, + { + type: 'REDIRECT_UPDATE', + rank: 1, + status: 'APPROVED', + data: { foo: 'bar' }, + }, + ]; + + const suggestions = await opportunity.addSuggestions(data); + + expect(suggestions).to.be.an('object'); + expect(suggestions.createdItems).to.be.an('array').with.length(2); + expect(suggestions.errorItems).to.be.an('array').with.length(0); + + suggestions.createdItems.forEach((suggestion, index) => { + expect(suggestion).to.be.an('object'); + + expect(suggestion.getOpportunityId()).to.equal(opportunity.getId()); + expect(uuidValidate(suggestion.getId())).to.be.true; + expect(isIsoDate(suggestion.getCreatedAt())).to.be.true; + expect(isIsoDate(suggestion.getUpdatedAt())).to.be.true; + + const record = sanitizeIdAndAuditFields('Suggestion', suggestion.toJSON()); + delete record.opportunityId; + + expect(record).to.eql(data[index]); + }); + }); + + it('updates the status of multiple suggestions', async () => { + const suggestions = sampleData.suggestions.slice(0, 3); + + await Suggestion.bulkUpdateStatus(suggestions, 'APPROVED'); + + const updatedSuggestions = await Promise.all( + suggestions.map((suggestion) => Suggestion.findById(suggestion.getId())), + ); + + updatedSuggestions.forEach((suggestion) => { + expect(suggestion.getStatus()).to.equal('APPROVED'); + }); + }); + + it('throws an error when adding a suggestion with invalid opportunity id', async () => { + const data = [ + { + opportunityId: 'invalid-opportunity-id', + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + data: { foo: 'bar' }, + }, + ]; + + const results = await Suggestion.createMany(data); + + expect(results.errorItems).to.be.an('array').with.length(1); + expect(results.createdItems).to.be.an('array').with.length(0); + expect(results.errorItems[0].error).to.be.an.instanceOf(ValidationError); + expect(results.errorItems[0].item).to.eql(data[0]); + }); + + it('removes a suggestion', async () => { + const suggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); + + await suggestion.remove(); + + const notFound = await Suggestion.findById(sampleData.suggestions[0].getId()); + expect(notFound).to.be.null; + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/util/db.js b/packages/spacecat-shared-data-access/test/it/util/db.js index 0def934c..8d1a6450 100755 --- a/packages/spacecat-shared-data-access/test/it/util/db.js +++ b/packages/spacecat-shared-data-access/test/it/util/db.js @@ -10,61 +10,52 @@ * governing permissions and limitations under the License. */ -import { DynamoDB } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'; +import { DynamoDB } from '@aws-sdk/client-dynamodb'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { spawn } from 'dynamo-db-local'; - -import { sleep } from '../../unit/util.js'; +import { createDataAccess } from '../../../src/service/index.js'; -async function waitForDynamoDBStartup(url, timeout = 20000, interval = 500) { - const startTime = Date.now(); - while (Date.now() - startTime < timeout) { - try { - // eslint-disable-next-line no-await-in-loop - const response = await fetch(url); - if (response.status === 400) { - return; - } - } catch (error) { - // eslint-disable-next-line no-console - console.log('DynamoDB Local not yet started', error.message); - } - // eslint-disable-next-line no-await-in-loop - await sleep(interval); - } - throw new Error('DynamoDB Local did not start within the expected time'); -} +export const TEST_DA_CONFIG = { + indexNameAllImportJobsByDateRange: 'spacecat-services-all-import-jobs-by-date-range', + indexNameAllImportJobsByStatus: 'spacecat-services-all-import-jobs-by-status', + indexNameAllKeyEventsBySiteId: 'spacecat-services-key-events-by-site-id', + indexNameAllLatestAuditScores: 'spacecat-services-all-latest-audit-scores', + indexNameAllOrganizations: 'spacecat-services-all-organizations', + indexNameAllOrganizationsByImsOrgId: 'spacecat-services-all-organizations-by-ims-org-id', + indexNameAllSites: 'spacecat-services-all-sites', + indexNameAllSitesByDeliveryType: 'spacecat-services-all-sites-by-delivery-type', + indexNameAllSitesOrganizations: 'spacecat-services-all-sites-organizations', + indexNameApiKeyByHashedApiKey: 'spacecat-services-api-key-by-hashed-api-key', + indexNameApiKeyByImsUserIdAndImsOrgId: 'spacecat-services-api-key-by-ims-user-id-and-ims-org-id', + indexNameImportUrlsByJobIdAndStatus: 'spacecat-services-all-import-urls-by-job-id-and-status', + pkAllConfigurations: 'ALL_CONFIGURATIONS', + pkAllImportJobs: 'ALL_IMPORT_JOBS', + pkAllLatestAudits: 'ALL_LATEST_AUDITS', + pkAllOrganizations: 'ALL_ORGANIZATIONS', + pkAllSites: 'ALL_SITES', + tableNameApiKeys: 'spacecat-services-api-keys', + tableNameAudits: 'spacecat-services-audits', + tableNameConfigurations: 'spacecat-services-configurations', + tableNameData: 'spacecat-services-data', + tableNameExperiments: 'spacecat-services-experiments', + tableNameImportJobs: 'spacecat-services-import-jobs', + tableNameImportUrls: 'spacecat-services-import-urls', + tableNameKeyEvents: 'spacecat-services-key-events', + tableNameLatestAudits: 'spacecat-services-latest-audits', + tableNameOrganizations: 'spacecat-services-organizations', + tableNameSiteCandidates: 'spacecat-services-site-candidates', + tableNameSiteTopPages: 'spacecat-services-site-top-pages', + tableNameSites: 'spacecat-services-sites', + tableNameSpacecatData: 'spacecat-data', +}; -let dynamoDbLocalProcess = null; -let dbClient = null; let docClient = null; -const getDynamoClients = async () => { - if (dynamoDbLocalProcess === null) { - process.env.AWS_REGION = 'local'; - process.env.AWS_ENDPOINT_URL_DYNAMODB = 'http://127.0.0.1:8000'; - process.env.AWS_DEFAULT_REGION = 'local'; - process.env.AWS_ACCESS_KEY_ID = 'dummy'; - process.env.AWS_SECRET_ACCESS_KEY = 'dummy'; - - dynamoDbLocalProcess = spawn({ - detached: true, - stdio: 'inherit', - port: 8000, - sharedDb: true, - }); - - await waitForDynamoDBStartup('http://127.0.0.1:8000'); - - process.on('SIGINT', () => { - if (dynamoDbLocalProcess) { - dynamoDbLocalProcess.kill(); - } - process.exit(); - }); - +const getDynamoClients = (config = {}) => { + let dbClient; + if (config?.region && config?.credentials) { + dbClient = new DynamoDB(config); + } else { dbClient = new DynamoDB({ endpoint: 'http://127.0.0.1:8000', region: 'local', @@ -73,18 +64,15 @@ const getDynamoClients = async () => { secretAccessKey: 'dummy', }, }); - docClient = DynamoDBDocument.from(dbClient); } + docClient = DynamoDBDocument.from(dbClient); return { dbClient, docClient }; }; -const closeDynamoClients = async () => { - if (dynamoDbLocalProcess) { - dynamoDbLocalProcess.kill(); - dynamoDbLocalProcess = null; - await sleep(2000); - } +export const getDataAccess = (config) => { + const { dbClient } = getDynamoClients(config); + return createDataAccess(TEST_DA_CONFIG, console, dbClient); }; -export { getDynamoClients, closeDynamoClients }; +export { getDynamoClients }; diff --git a/packages/spacecat-shared-data-access/test/it/util/generateSampleData.js b/packages/spacecat-shared-data-access/test/it/util/generateLegacySampleData.js similarity index 85% rename from packages/spacecat-shared-data-access/test/it/util/generateSampleData.js rename to packages/spacecat-shared-data-access/test/it/util/generateLegacySampleData.js index 4aaec9fa..037f3177 100644 --- a/packages/spacecat-shared-data-access/test/it/util/generateSampleData.js +++ b/packages/spacecat-shared-data-access/test/it/util/generateLegacySampleData.js @@ -14,46 +14,13 @@ import { v4 as uuidv4 } from 'uuid'; -import schema from '../../../docs/schema.json' with { type: 'json' }; import { SITE_CANDIDATE_STATUS } from '../../../src/models/site-candidate.js'; import { createKeyEvent, KEY_EVENT_TYPES } from '../../../src/models/key-event.js'; import { KeyEventDto } from '../../../src/dto/key-event.js'; import { generateRandomAudit } from './auditUtils.js'; -import { createTable, deleteTable } from './tableOperations.js'; import { getDynamoClients } from './db.js'; -/** - * Creates all tables defined in a schema. - * - * Iterates over a predefined schema object and creates each table using the createTable function. - * The schema object should define all required attributes and configurations for each table. - * - * @param {AWS.DynamoDB.DocumentClient} dbClient - The DynamoDB client to use for creating tables. - */ -async function createTablesFromSchema(dbClient) { - const creationPromises = schema.DataModel.map( - (tableDefinition) => createTable(dbClient, tableDefinition), - ); - await Promise.all(creationPromises); -} - -/** - * Deletes a predefined set of tables from the database. - * - * Iterates over a list of table names and deletes each one using the deleteTable function. - * This is typically used to clean up the database before creating new tables or - * generating test data. - * - * @param {Object} dbClient - The DynamoDB client to use for creating tables. - * @param {Array} tableNames - An array of table names to delete. - * @returns {Promise} A promise that resolves when all tables have been deleted. - */ -export async function deleteExistingTables(dbClient, tableNames) { - const deletionPromises = tableNames.map((tableName) => deleteTable(dbClient, tableName)); - await Promise.all(deletionPromises); -} - /** * Performs a batch write operation for a specified table in DynamoDB. * @@ -151,7 +118,7 @@ function generateAuditData( * // Example usage * generateSampleData(20, 10); // Generates 20 sites with 10 audits per type for each site */ -export default async function generateSampleData( +export default async function generateLegacySampleData( config, numberOfOrganizations = 3, numberOfSites = 10, @@ -161,23 +128,8 @@ export default async function generateSampleData( numberOfKeyEvents = 10, numberOfExperiments = 3, ) { - const { dbClient, docClient } = await getDynamoClients(); + const { docClient } = getDynamoClients(); console.time('Sample data generated in'); - await deleteExistingTables(dbClient, [ - config.tableNameSites, - config.tableNameSiteCandidates, - config.tableNameAudits, - config.tableNameLatestAudits, - config.tableNameOrganizations, - config.tableNameConfigurations, - config.tableNameSiteTopPages, - config.tableNameKeyEvents, - config.tableNameExperiments, - config.tableNameApiKeys, - config.tableNameImportJobs, - config.tableNameImportUrls, - ]); - await createTablesFromSchema(dbClient); const auditTypes = ['lhs-mobile', 'cwv']; const sites = []; diff --git a/packages/spacecat-shared-data-access/test/it/util/seed.js b/packages/spacecat-shared-data-access/test/it/util/seed.js new file mode 100644 index 00000000..8d4ba234 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/util/seed.js @@ -0,0 +1,80 @@ +/* + * 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-disable no-console */ + +import { idNameToEntityName } from '../../../src/v2/util/util.js'; +import fixtures from '../../fixtures/index.fixtures.js'; + +import generateLegacySampleData from './generateLegacySampleData.js'; +import { getDataAccess, getDynamoClients, TEST_DA_CONFIG } from './db.js'; +import { createTablesFromSchema, deleteExistingTables } from './tableOperations.js'; + +const resetDatabase = async () => { + const { dbClient } = getDynamoClients(); + await deleteExistingTables(dbClient, [ + TEST_DA_CONFIG.tableNameApiKeys, + TEST_DA_CONFIG.tableNameAudits, + TEST_DA_CONFIG.tableNameConfigurations, + TEST_DA_CONFIG.tableNameData, + TEST_DA_CONFIG.tableNameExperiments, + TEST_DA_CONFIG.tableNameImportJobs, + TEST_DA_CONFIG.tableNameImportUrls, + TEST_DA_CONFIG.tableNameKeyEvents, + TEST_DA_CONFIG.tableNameLatestAudits, + TEST_DA_CONFIG.tableNameOrganizations, + TEST_DA_CONFIG.tableNameSiteCandidates, + TEST_DA_CONFIG.tableNameSiteTopPages, + TEST_DA_CONFIG.tableNameSites, + ]); + await createTablesFromSchema(dbClient); +}; + +const seedV2Fixtures = async () => { + const dataAccess = getDataAccess(); + const sampleData = {}; + + for (const [key, data] of Object.entries(fixtures)) { + console.log(`Seeding ${key}...`); + + if (!Array.isArray(data) || data.length === 0) { + console.log(`No data to seed for ${key}.`); + // eslint-disable-next-line no-continue + continue; + } + + const modelName = idNameToEntityName(key); + const Model = dataAccess[modelName]; + + if (!Model) { + throw new Error(`Model not found for ${modelName}`); + } + + // eslint-disable-next-line no-await-in-loop + const result = await Model.createMany(data); + sampleData[key] = result.createdItems; + + if (result.errorItems.length > 0) { + throw new Error(`Error seeding ${key}: ${JSON.stringify(result.errorItems, null, 2)}`); + } + + console.log(`Successfully seeded ${key}.`); + } + + return sampleData; +}; + +export const seedDatabase = async () => { + await resetDatabase(); + await generateLegacySampleData(TEST_DA_CONFIG); + return seedV2Fixtures(); +}; diff --git a/packages/spacecat-shared-data-access/test/it/util/tableOperations.js b/packages/spacecat-shared-data-access/test/it/util/tableOperations.js old mode 100644 new mode 100755 index 54af94f6..63e57fd3 --- a/packages/spacecat-shared-data-access/test/it/util/tableOperations.js +++ b/packages/spacecat-shared-data-access/test/it/util/tableOperations.js @@ -14,6 +14,8 @@ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb'; +import schema from '../../../docs/schema.json' with { type: 'json' }; + /** * Creates a DynamoDB table based on the provided table definition. * @@ -160,4 +162,40 @@ async function deleteTable(dbClient, tableName) { } } -export { createTable, deleteTable }; +/** + * Creates all tables defined in a schema. + * + * Iterates over a predefined schema object and creates each table using the createTable function. + * The schema object should define all required attributes and configurations for each table. + * + * @param {AWS.DynamoDB.DocumentClient} dbClient - The DynamoDB client to use for creating tables. + */ +async function createTablesFromSchema(dbClient) { + const creationPromises = schema.DataModel.map( + (tableDefinition) => createTable(dbClient, tableDefinition), + ); + await Promise.all(creationPromises); +} + +/** + * Deletes a predefined set of tables from the database. + * + * Iterates over a list of table names and deletes each one using the deleteTable function. + * This is typically used to clean up the database before creating new tables or + * generating test data. + * + * @param {Object} dbClient - The DynamoDB client to use for creating tables. + * @param {Array} tableNames - An array of table names to delete. + * @returns {Promise} A promise that resolves when all tables have been deleted. + */ +async function deleteExistingTables(dbClient, tableNames) { + const deletionPromises = tableNames.map((tableName) => deleteTable(dbClient, tableName)); + await Promise.all(deletionPromises); +} + +export { + createTablesFromSchema, + deleteExistingTables, + createTable, + deleteTable, +}; diff --git a/packages/spacecat-shared-data-access/test/it/util/util.js b/packages/spacecat-shared-data-access/test/it/util/util.js old mode 100644 new mode 100755 index c2ce6fc1..a6995c0d --- a/packages/spacecat-shared-data-access/test/it/util/util.js +++ b/packages/spacecat-shared-data-access/test/it/util/util.js @@ -9,6 +9,8 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +import { removeElectroProperties } from '../../../src/v2/util/util.js'; + const randomDate = (start, end) => { if (start.getTime() >= end.getTime()) { throw new Error('start must be before end'); @@ -24,4 +26,30 @@ const getRandomDecimal = (precision) => parseFloat(Math.random().toFixed(precisi // Generates a random integer up to a given maximum const getRandomInt = (max) => Math.floor(Math.random() * max); -export { randomDate, getRandomDecimal, getRandomInt }; +const sanitizeRecord = (record, idName) => { + const sanitizedRecord = removeElectroProperties({ ...record }); + + delete sanitizedRecord[idName]; + delete sanitizedRecord.createdAt; + delete sanitizedRecord.updatedAt; + + return sanitizedRecord; +}; + +const getExecutionOptions = (options) => { + const { limit, order = 'asc' } = options; + + return { + ...(limit > 0 && { limit }), + order, + }; +}; + +export { + getExecutionOptions, + getRandomDecimal, + getRandomInt, + randomDate, + removeElectroProperties, + sanitizeRecord, +}; diff --git a/packages/spacecat-shared-data-access/test/it/v2/index.test.js b/packages/spacecat-shared-data-access/test/it/v2/index.test.js deleted file mode 100755 index fe6ccd38..00000000 --- a/packages/spacecat-shared-data-access/test/it/v2/index.test.js +++ /dev/null @@ -1,545 +0,0 @@ -/* - * 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 */ -/* eslint-disable no-console */ - -import { isIsoDate } from '@adobe/spacecat-shared-utils'; - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import { v4 as uuid, validate as uuidValidate } from 'uuid'; - -import SCHEMA from '../../../docs/schema.json' with { type: 'json' }; -import { createDataAccess } from '../../../src/service/index.js'; -import { ValidationError } from '../../../src/index.js'; - -import { closeDynamoClients, getDynamoClients } from '../util/db.js'; -import { createTable, deleteTable } from '../util/tableOperations.js'; - -use(chaiAsPromised); - -const DATA_TABLE_NAME = 'spacecat-services-data'; - -const setupDb = async (client, table) => { - await deleteTable(client, table); - - const schema = SCHEMA.DataModel.find((model) => model.TableName === table); - await createTable(client, schema); -}; - -const generateSampleData = async (dataAccess, siteId) => { - const { Opportunity, Suggestion } = dataAccess; - const sampleData = { opportunities: [], suggestions: [] }; - - for (let i = 0; i < 10; i += 1) { - const type = i % 2 === 0 ? 'broken-backlinks' : 'broken-internal-links'; - const status = i % 2 === 0 ? 'NEW' : 'IN_PROGRESS'; - const data = type === 'broken-backlinks' - ? { brokenLinks: [`foo-${i}`] } - : { brokenInternalLinks: [`bar-${i}`] }; - - // eslint-disable-next-line no-await-in-loop - const opportunity = await Opportunity.create({ - siteId, - auditId: uuid(), - title: `Opportunity ${i}`, - description: `Description ${i}`, - runbook: `https://example${i}.com`, - type, - origin: 'AI', - guidance: { - foo: `bar-${i}`, - }, - status, - data, - }); - - sampleData.opportunities.push(opportunity); - - // generate suggestions for each opportunity - for (let j = 0; j < 3; j += 1) { - // eslint-disable-next-line no-await-in-loop - const suggestion = await Suggestion.create({ - opportunityId: opportunity.getId(), - title: `Suggestion ${j} for Opportunity ${i}`, - description: `Description for Suggestion ${j} of Opportunity ${i}`, - data: { foo: `bar-${j}` }, - type: 'CODE_CHANGE', - rank: j, - status: 'NEW', - }); - - sampleData.suggestions.push(suggestion); - } - } - - return sampleData; -}; - -const removeElectroProperties = (record) => { /* eslint-disable no-underscore-dangle */ - const cleanedRecord = { ...record }; - delete cleanedRecord.opportunityId; - delete cleanedRecord.suggestionId; - delete cleanedRecord.createdAt; - delete cleanedRecord.updatedAt; - delete cleanedRecord.sk; - delete cleanedRecord.pk; - delete cleanedRecord.gsi1pk; - delete cleanedRecord.gsi1sk; - delete cleanedRecord.gsi2pk; - delete cleanedRecord.gsi2sk; - delete cleanedRecord.__edb_e__; - delete cleanedRecord.__edb_v__; - - return cleanedRecord; -}; - -// eslint-disable-next-line func-names -describe('Opportunity & Suggestion IT', function () { - this.timeout(30000); - - const siteId = uuid(); - - let dataAccess; - let sampleData; - - before(async () => { - const { dbClient } = await getDynamoClients(); - - await setupDb(dbClient, DATA_TABLE_NAME); - - dataAccess = createDataAccess({ tableNameData: DATA_TABLE_NAME }, console); - - sampleData = await generateSampleData(dataAccess, siteId); - }); - - after(async () => { - await closeDynamoClients(); - }); - - describe('Opportunity', () => { - it('finds one opportunity by id', async () => { - const { Opportunity } = dataAccess; - - const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - - expect(opportunity).to.be.an('object'); - expect(opportunity.record).to.eql(sampleData.opportunities[0].record); - - const suggestions = await opportunity.getSuggestions(); - expect(suggestions).to.be.an('array').with.length(3); - - const parentOpportunity = await suggestions[0].getOpportunity(); - expect(parentOpportunity).to.be.an('object'); - expect(parentOpportunity.record).to.eql(sampleData.opportunities[0].record); - }); - - it('finds all opportunities by siteId and status', async () => { - const { Opportunity } = dataAccess; - - const opportunities = await Opportunity.allBySiteIdAndStatus(siteId, 'NEW'); - - expect(opportunities).to.be.an('array').with.length(5); - }); - - it('partially updates one opportunity by id', async () => { - const { Opportunity } = dataAccess; - - // retrieve the opportunity by ID - const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - expect(opportunity).to.be.an('object'); - expect(opportunity.record).to.eql(sampleData.opportunities[0].record); - - // apply updates - const updates = { - runbook: 'https://example-updated.com', - status: 'IN_PROGRESS', - }; - - opportunity - .setRunbook(updates.runbook) - .setStatus(updates.status); - - expect(() => { - opportunity.setAuditId('invalid-audit-id'); - }).to.throw(Error); - - await opportunity.save(); - - // validate in-memory updates - expect(opportunity.getRunbook()).to.equal(updates.runbook); - expect(opportunity.getStatus()).to.equal(updates.status); - - // validate unchanged fields - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - runbook, status, updatedAt, ...originalUnchangedFields - } = sampleData.opportunities[0].record; - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - runbook: _, status: __, updatedAt: ___, ...actualUnchangedFields - } = opportunity.record; - - expect(actualUnchangedFields).to.eql(originalUnchangedFields); - - // validate persistence of updates - const storedOpportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - expect(storedOpportunity.getRunbook()).to.equal(updates.runbook); - expect(storedOpportunity.getStatus()).to.equal(updates.status); - - // validate timestamps or audit logs - expect(new Date(storedOpportunity.record.updatedAt)).to.be.greaterThan( - new Date(sampleData.opportunities[0].record.updatedAt), - ); - - // validate persisted record matches in-memory state - const storedWithoutUpdatedAt = { ...storedOpportunity.record }; - const inMemoryWithoutUpdatedAt = { ...opportunity.record }; - delete storedWithoutUpdatedAt.updatedAt; - delete inMemoryWithoutUpdatedAt.updatedAt; - - expect(storedWithoutUpdatedAt).to.eql(inMemoryWithoutUpdatedAt); - }); - - it('finds all opportunities by siteId', async () => { - const { Opportunity } = dataAccess; - - const opportunities = await Opportunity.allBySiteId(siteId); - - expect(opportunities).to.be.an('array').with.length(10); - }); - - it('creates a new opportunity', async () => { - const { Opportunity } = dataAccess; - const data = { - siteId, - auditId: uuid(), - title: 'New Opportunity', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - guidance: { foo: 'bar' }, - data: { brokenLinks: ['https://example.com'] }, - }; - - const opportunity = await Opportunity.create(data); - - expect(opportunity).to.be.an('object'); - - expect(uuidValidate(opportunity.getId())).to.be.true; - expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; - expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; - - delete opportunity.record.opportunityId; - delete opportunity.record.createdAt; - delete opportunity.record.updatedAt; - expect(opportunity.record).to.eql(data); - }); - - it('deletes an opportunity', async () => { - const { Opportunity } = dataAccess; - - const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - - await opportunity.remove(); - - const notFound = await Opportunity.findById(sampleData.opportunities[0].getId()); - await expect(notFound).to.be.null; - }); - - it('creates many opportunities', async () => { - const { Opportunity } = dataAccess; - const data = [ - { - siteId, - auditId: uuid(), - title: 'New Opportunity 1', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - data: { brokenLinks: ['https://example.com'] }, - }, - { - siteId, - auditId: uuid(), - title: 'New Opportunity 2', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-internal-links', - origin: 'AI', - status: 'NEW', - data: { brokenInternalLinks: ['https://example.com'] }, - }, - ]; - - const opportunities = await Opportunity.createMany(data); - - expect(opportunities).to.be.an('object'); - expect(opportunities.createdItems).to.be.an('array').with.length(2); - expect(opportunities.errorItems).to.be.an('array').with.length(0); - - opportunities.createdItems.forEach((opportunity, index) => { - expect(opportunity).to.be.an('object'); - - expect(uuidValidate(opportunity.getId())).to.be.true; - expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; - expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; - - const { record } = opportunity; - delete record.opportunityId; - delete record.createdAt; - delete record.updatedAt; - delete record.sk; - delete record.pk; - delete record.gsi1pk; - delete record.gsi1sk; - delete record.gsi2pk; - delete record.gsi2sk; - // eslint-disable-next-line no-underscore-dangle - delete record.__edb_e__; - // eslint-disable-next-line no-underscore-dangle - delete record.__edb_v__; - expect(record).to.eql(data[index]); - }); - }); - - it('fails to create many opportunities with invalid data', async () => { - const { Opportunity } = dataAccess; - const data = [ - { - siteId, - auditId: uuid(), - title: 'New Opportunity 1', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - data: { brokenLinks: ['https://example.com'] }, - }, - { - siteId, - auditId: uuid(), - title: 'New Opportunity 2', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-internal-links', - origin: 'AI', - status: 'NEW', - data: { brokenInternalLinks: ['https://example.com'] }, - }, - { - siteId, - auditId: uuid(), - title: 'New Opportunity 3', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-internal-links', - origin: 'AI', - status: 'NEW', - data: { brokenInternalLinks: ['https://example.com'] }, - }, - ]; - - data[2].title = null; - - const result = await Opportunity.createMany(data); - - expect(result).to.be.an('object'); - expect(result).to.have.property('createdItems'); - expect(result).to.have.property('errorItems'); - - expect(result.createdItems).to.be.an('array').with.length(2); - expect(removeElectroProperties(result.createdItems[0].record)).to.eql(data[0]); - expect(removeElectroProperties(result.createdItems[1].record)).to.eql(data[1]); - expect(result.errorItems).to.be.an('array').with.length(1); - expect(result.errorItems[0].item).to.eql(data[2]); - expect(result.errorItems[0].error).to.be.an.instanceOf(ValidationError); - }); - }); - - describe('Suggestion', () => { - it('finds one suggestion by id', async () => { - const { Suggestion } = dataAccess; - const sampleSuggestion = sampleData.suggestions[6]; - - const suggestion = await Suggestion.findById(sampleSuggestion.getId()); - - expect(suggestion).to.be.an('object'); - expect(suggestion.record).to.eql(sampleSuggestion.record); - - const opportunity = await suggestion.getOpportunity(); - expect(opportunity).to.be.an('object'); - expect(opportunity.record).to.eql(sampleData.opportunities[2].record); - }); - - it('partially updates one suggestion by id', async () => { - const { Suggestion } = dataAccess; - - // retrieve the suggestion by ID - const suggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); - expect(suggestion).to.be.an('object'); - expect(suggestion.record).to.eql(sampleData.suggestions[0].record); - - // apply updates - const updates = { - status: 'APPROVED', - }; - - await suggestion - .setStatus(updates.status) - .save(); - - // validate in-memory updates - expect(suggestion.getStatus()).to.equal(updates.status); - - // validate unchanged fields - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - status, updatedAt, ...originalUnchangedFields - } = sampleData.suggestions[0].record; - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - status: _, updatedAt: __, ...actualUnchangedFields - } = suggestion.record; - - expect(actualUnchangedFields).to.eql(originalUnchangedFields); - - // validate persistence of updates - const storedSuggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); - expect(storedSuggestion.getStatus()).to.equal(updates.status); - - // validate timestamps or audit logs - expect(new Date(storedSuggestion.record.updatedAt)).to.be.greaterThan( - new Date(sampleData.suggestions[0].record.updatedAt), - ); - - // validate persisted record matches in-memory state - const storedWithoutUpdatedAt = { ...storedSuggestion.record }; - const inMemoryWithoutUpdatedAt = { ...suggestion.record }; - delete storedWithoutUpdatedAt.updatedAt; - delete inMemoryWithoutUpdatedAt.updatedAt; - - expect(storedWithoutUpdatedAt).to.eql(inMemoryWithoutUpdatedAt); - }); - - it('finds all suggestions by opportunityId', async () => { - const { Suggestion } = dataAccess; - - const suggestions = await Suggestion.allByOpportunityId(sampleData.opportunities[0].getId()); - - expect(suggestions).to.be.an('array').with.length(3); - }); - - it('finds all suggestions by opportunityId and status', async () => { - const { Suggestion } = dataAccess; - - const suggestions = await Suggestion.allByOpportunityIdAndStatus( - sampleData.opportunities[0].getId(), - 'NEW', - ); - - expect(suggestions).to.be.an('array').with.length(2); - }); - - it('adds many suggestions to an opportunity', async () => { - const opportunity = sampleData.opportunities[0]; - const data = [ - { - type: 'CODE_CHANGE', - rank: 0, - status: 'NEW', - data: { foo: 'bar' }, - }, - { - type: 'REDIRECT_UPDATE', - rank: 1, - status: 'APPROVED', - data: { foo: 'bar' }, - }, - ]; - - const suggestions = await opportunity.addSuggestions(data); - - expect(suggestions).to.be.an('object'); - expect(suggestions.createdItems).to.be.an('array').with.length(2); - expect(suggestions.errorItems).to.be.an('array').with.length(0); - - suggestions.createdItems.forEach((suggestion, index) => { - expect(suggestion).to.be.an('object'); - - expect(suggestion.getOpportunityId()).to.equal(opportunity.getId()); - expect(uuidValidate(suggestion.getId())).to.be.true; - expect(isIsoDate(suggestion.getCreatedAt())).to.be.true; - expect(isIsoDate(suggestion.getUpdatedAt())).to.be.true; - - const { record } = suggestion; - delete record.opportunityId; - delete record.suggestionId; - delete record.createdAt; - delete record.updatedAt; - delete record.sk; - delete record.pk; - delete record.gsi1pk; - delete record.gsi1sk; - delete record.gsi2pk; - delete record.gsi2sk; - // eslint-disable-next-line no-underscore-dangle - delete record.__edb_e__; - // eslint-disable-next-line no-underscore-dangle - delete record.__edb_v__; - expect(record).to.eql(data[index]); - }); - }); - - it('updates the status of multiple suggestions', async () => { - const { Suggestion } = dataAccess; - - const suggestions = sampleData.suggestions.slice(0, 3); - - await Suggestion.bulkUpdateStatus(suggestions, 'APPROVED'); - - const updatedSuggestions = await Promise.all( - suggestions.map((suggestion) => Suggestion.findById(suggestion.getId())), - ); - - updatedSuggestions.forEach((suggestion) => { - expect(suggestion.getStatus()).to.equal('APPROVED'); - }); - }); - - it('throws an error when adding a suggestion with invalid opportunity id', async () => { - const { Suggestion } = dataAccess; - const data = [ - { - opportunityId: 'invalid-opportunity-id', - type: 'CODE_CHANGE', - rank: 0, - status: 'NEW', - data: { foo: 'bar' }, - }, - ]; - - const results = await Suggestion.createMany(data); - - expect(results.errorItems).to.be.an('array').with.length(1); - expect(results.createdItems).to.be.an('array').with.length(0); - expect(results.errorItems[0].error).to.be.an.instanceOf(ValidationError); - expect(results.errorItems[0].item).to.eql(data[0]); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/index.test.js b/packages/spacecat-shared-data-access/test/unit/index.test.js index 68a6abaa..af9dbf03 100644 --- a/packages/spacecat-shared-data-access/test/unit/index.test.js +++ b/packages/spacecat-shared-data-access/test/unit/index.test.js @@ -25,7 +25,11 @@ describe('Data Access Wrapper Tests', () => { mockFn = sinon.stub().resolves('function response'); mockContext = { env: {}, - log: sinon.spy(), + log: { + info: sinon.spy(), + debug: sinon.spy(), + error: sinon.spy(), + }, }; mockRequest = {}; }); diff --git a/packages/spacecat-shared-data-access/test/unit/service/index.test.js b/packages/spacecat-shared-data-access/test/unit/service/index.test.js index 310eda2a..6ef0f5e3 100644 --- a/packages/spacecat-shared-data-access/test/unit/service/index.test.js +++ b/packages/spacecat-shared-data-access/test/unit/service/index.test.js @@ -111,7 +111,18 @@ describe('Data Access Object Tests', () => { ]; const electroServiceFunctions = [ + 'ApiKey', + 'Audit', + 'Configuration', + 'Experiment', + 'ImportJob', + 'ImportUrl', + 'KeyEvent', 'Opportunity', + 'Organization', + 'Site', + 'SiteCandidate', + 'SiteTopPage', 'Suggestion', ]; diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.collection.test.js new file mode 100755 index 00000000..a226da75 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.collection.test.js @@ -0,0 +1,61 @@ +/* + * 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 ApiKey from '../../../../../src/v2/models/api-key/api-key.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ApiKeyCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + apiKeyId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(ApiKey, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ApiKeyCollection 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'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.model.test.js new file mode 100755 index 00000000..b8058527 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.model.test.js @@ -0,0 +1,197 @@ +/* + * 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 ApiKey from '../../../../../src/v2/models/api-key/api-key.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ApiKeyModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + apiKeyId: 'sug12345', + hashedApiKey: 'someHashedApiKey', + imsUserId: 'someImsUserId', + imsOrgId: 'someImsOrgId', + name: 'someName', + deletedAt: null, + expiresAt: null, + revokedAt: null, + scopes: [ + { + domains: ['someDomain'], + actions: ['someAction'], + }, + ], + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(ApiKey, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the ApiKey instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('apiKeyId', () => { + it('gets apiKeyId', () => { + expect(instance.getId()).to.equal('sug12345'); + }); + }); + + describe('hashedApiKey', () => { + it('gets hashedApiKey', () => { + expect(instance.getHashedApiKey()).to.equal('someHashedApiKey'); + }); + + it('sets hashedApiKey', () => { + const newHashedApiKey = 'newHashedApiKey'; + instance.setHashedApiKey(newHashedApiKey); + expect(instance.getHashedApiKey()).to.equal(newHashedApiKey); + }); + }); + + describe('imsUserId', () => { + it('gets imsUserId', () => { + expect(instance.getImsUserId()).to.equal('someImsUserId'); + }); + + it('sets imsUserId', () => { + const newImsUserId = 'newImsUserId'; + instance.setImsUserId(newImsUserId); + expect(instance.getImsUserId()).to.equal(newImsUserId); + }); + }); + + describe('imsOrgId', () => { + it('gets imsOrgId', () => { + expect(instance.getImsOrgId()).to.equal('someImsOrgId'); + }); + + it('sets imsOrgId', () => { + const newImsOrgId = 'newImsOrgId'; + instance.setImsOrgId(newImsOrgId); + expect(instance.getImsOrgId()).to.equal(newImsOrgId); + }); + }); + + describe('name', () => { + it('gets name', () => { + expect(instance.getName()).to.equal('someName'); + }); + + it('sets name', () => { + const newName = 'newName'; + instance.setName(newName); + expect(instance.getName()).to.equal(newName); + }); + }); + + describe('scopes', () => { + it('gets scopes', () => { + expect(instance.getScopes()).to.deep.equal([ + { + domains: ['someDomain'], + actions: ['someAction'], + }, + ]); + }); + + it('sets scopes', () => { + const newScopes = [ + { + domains: ['newDomain'], + actions: ['newAction'], + }, + ]; + instance.setScopes(newScopes); + expect(instance.getScopes()).to.deep.equal(newScopes); + }); + }); + + describe('isValid', () => { + it('returns true when the ApiKey is valid', () => { + expect(instance.isValid()).to.equal(true); + }); + + it('returns false when the ApiKey is deleted', () => { + instance.setDeletedAt('2022-01-01T00:00:00.000Z'); + expect(instance.isValid()).to.equal(false); + }); + + it('returns false when the ApiKey is revoked', () => { + instance.setRevokedAt('2022-01-01T00:00:00.000Z'); + expect(instance.isValid()).to.equal(false); + }); + + it('returns false when the ApiKey is expired', () => { + instance.setExpiresAt('2022-01-01T00:00:00.000Z'); + expect(instance.isValid()).to.equal(false); + }); + }); + + describe('deletedAt', () => { + it('gets deletedAt', () => { + expect(instance.getDeletedAt()).to.equal(null); + }); + + it('sets deletedAt', () => { + const deletedAtIsoDate = '2024-01-01T00:00:00.000Z'; + instance.setDeletedAt(deletedAtIsoDate); + expect(instance.getDeletedAt()).to.equal(deletedAtIsoDate); + }); + }); + + describe('expiresAt', () => { + it('gets expiresAt', () => { + expect(instance.getExpiresAt()).to.equal(null); + }); + + it('sets expiresAt', () => { + const expiresAtIsoDate = '2024-01-01T00:00:00.000Z'; + instance.setExpiresAt(expiresAtIsoDate); + expect(instance.getExpiresAt()).to.equal(expiresAtIsoDate); + }); + }); + + describe('revokedAt', () => { + it('gets revokedAt', () => { + expect(instance.getRevokedAt()).to.equal(null); + }); + + it('sets revokedAt', () => { + const revokedAtIsoDate = '2024-01-01T00:00:00.000Z'; + instance.setRevokedAt(revokedAtIsoDate); + expect(instance.getRevokedAt()).to.equal(revokedAtIsoDate); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.collection.test.js new file mode 100755 index 00000000..3f902c0c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.collection.test.js @@ -0,0 +1,61 @@ +/* + * 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 Audit from '../../../../../src/v2/models/audit/audit.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('AuditCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + auditId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Audit, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the AuditCollection 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'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.model.test.js new file mode 100755 index 00000000..6dde89f7 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.model.test.js @@ -0,0 +1,185 @@ +/* + * 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 Audit, { validateAuditResult } from '../../../../../src/v2/models/audit/audit.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('AuditModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + auditId: 'a12345', + auditResult: { foo: 'bar' }, + auditType: 'someAuditType', + auditedAt: '2024-01-01T00:00:00.000Z', + fullAuditRef: 'someFullAuditRef', + isLive: true, + isError: false, + siteId: 'site12345', + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Audit, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Audit instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('auditId', () => { + it('gets auditId', () => { + expect(instance.getId()).to.equal('a12345'); + }); + }); + + describe('auditResult', () => { + it('gets auditResult', () => { + expect(instance.getAuditResult()).to.deep.equal({ foo: 'bar' }); + }); + + it('sets auditResult', () => { + const newAuditResult = { bar: 'baz' }; + instance.setAuditResult(newAuditResult); + expect(instance.getAuditResult()).to.deep.equal(newAuditResult); + }); + }); + + describe('auditType', () => { + it('gets auditType', () => { + expect(instance.getAuditType()).to.equal('someAuditType'); + }); + + it('sets auditType', () => { + const newAuditType = 'someNewAuditType'; + instance.setAuditType(newAuditType); + expect(instance.getAuditType()).to.equal(newAuditType); + }); + }); + + describe('auditedAt', () => { + it('gets auditedAt', () => { + expect(instance.getAuditedAt()).to.equal('2024-01-01T00:00:00.000Z'); + }); + + it('sets auditedAt', () => { + const newAuditedAt = '2024-01-02T00:00:00.000Z'; + instance.setAuditedAt(newAuditedAt); + expect(instance.getAuditedAt()).to.equal(newAuditedAt); + }); + }); + + describe('fullAuditRef', () => { + it('gets fullAuditRef', () => { + expect(instance.getFullAuditRef()).to.equal('someFullAuditRef'); + }); + + it('sets fullAuditRef', () => { + const newFullAuditRef = 'someNewFullAuditRef'; + instance.setFullAuditRef(newFullAuditRef); + expect(instance.getFullAuditRef()).to.equal(newFullAuditRef); + }); + }); + + describe('isLive', () => { + it('gets isLive', () => { + expect(instance.getIsLive()).to.be.true; + }); + + it('sets isLive', () => { + instance.setIsLive(false); + expect(instance.getIsLive()).to.be.false; + }); + }); + + describe('isError', () => { + it('gets isError', () => { + expect(instance.getIsError()).to.be.false; + }); + + it('sets isError', () => { + instance.setIsError(true); + expect(instance.getIsError()).to.be.true; + }); + }); + + describe('siteId', () => { + it('gets siteId', () => { + expect(instance.getSiteId()).to.equal('site12345'); + }); + + it('sets siteId', () => { + const newSiteId = '978cbf56-699c-4e91-b719-13e5fd9a0374'; + instance.setSiteId(newSiteId); + expect(instance.getSiteId()).to.equal(newSiteId); + }); + }); + + describe('getScores', () => { + it('returns the scores from the audit result', () => { + mockRecord.auditResult = { scores: { foo: 'bar' } }; + expect(instance.getScores()).to.deep.equal({ foo: 'bar' }); + }); + }); + + describe('validateAuditResult', () => { + it('throws an error if auditResult is not an object or array', () => { + expect(() => validateAuditResult(null, 'someAuditType')) + .to.throw('Audit result must be an object or array'); + }); + + it('throws an error if auditResult is an object and does not contain scores', () => { + expect(() => validateAuditResult({ foo: 'bar' }, 'lhs-mobile')) + .to.throw("Missing scores property for audit type 'lhs-mobile'"); + }); + + it('throws an error if auditResult is an object and does not contain expected properties', () => { + mockRecord.auditResult = { scores: { foo: 'bar' } }; + expect(() => validateAuditResult(mockRecord.auditResult, 'lhs-desktop')) + .to.throw("Missing expected property 'performance' for audit type 'lhs-desktop'"); + }); + + it('returns true if the auditResult represents a runtime error', () => { + mockRecord.auditResult = { runtimeError: { code: 'someErrorCode' } }; + expect(validateAuditResult(mockRecord.auditResult, 'someAuditType')).to.be.true; + }); + + it('returns true if auditResult is an object and contains expected properties', () => { + mockRecord.auditResult = { + scores: { + performance: 1, seo: 1, accessibility: 1, 'best-practices': 1, + }, + }; + expect(validateAuditResult(mockRecord.auditResult, 'lhs-mobile')).to.be.true; + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js deleted file mode 100644 index aec8e1fd..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js +++ /dev/null @@ -1,316 +0,0 @@ -/* - * 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 { ElectroValidationError } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import BaseCollection from '../../../../src/v2/models/base.collection.js'; - -chaiUse(chaiAsPromised); - -describe('BaseCollection', () => { - let baseCollectionInstance; - let mockElectroService; - let mockModelFactory; - let mockLogger; - - const mockRecord = { - id: 'ef39921f-9a02-41db-b491-02c98987d956', - data: { - someKey: 'someValue', - }, - }; - const mockEntityModel = { - data: { ...mockRecord }, - }; - - beforeEach(() => { - mockModelFactory = { - getCollection: stub(), - }; - - mockLogger = { - error: spy(), - warn: spy(), - }; - - mockElectroService = { - entities: { - mockentitymodel: { - get: stub(), - put: stub(), - create: stub(), - query: stub(), - model: { - name: 'mockentitymodel', - table: 'mockentitymodel', - }, - }, - }, - }; - - baseCollectionInstance = new BaseCollection( - mockElectroService, - mockModelFactory, - class MockEntityModel { - constructor(service, factory, data) { - this.data = data; - } - - // eslint-disable-next-line class-methods-use-this - _cacheReference() {} - }, - mockLogger, - ); - }); - - describe('findById', () => { - it('returns the entity if found', async () => { - const mockFindResult = { data: mockRecord }; - mockElectroService.entities.mockentitymodel.get.returns( - { go: () => Promise.resolve(mockFindResult) }, - ); - - const result = await baseCollectionInstance.findById('ef39921f-9a02-41db-b491-02c98987d956'); - expect(result).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.get.calledOnce).to.be.true; - }); - - it('returns null if the entity is not found', async () => { - mockElectroService.entities.mockentitymodel.get.returns( - { go: () => Promise.resolve(null) }, - ); - - const result = await baseCollectionInstance.findById('ef39921f-9a02-41db-b491-02c98987d956'); - expect(result).to.be.null; - expect(mockElectroService.entities.mockentitymodel.get.calledOnce).to.be.true; - }); - }); - - describe('findByIndexKeys', () => { - it('throws error if keys is not provided', async () => { - await expect(baseCollectionInstance.findByIndexKeys()) - .to.be.rejectedWith('Failed to find by index keys [mockentitymodel]: keys are required'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - - it('throws error if index is not found', async () => { - await expect(baseCollectionInstance.findByIndexKeys({ someKey: 'someValue' })) - .to.be.rejectedWith('Failed to find by index keys [mockentitymodel]: index [bySomeKey] not found'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('create', () => { - it('throws an error if the record is empty', async () => { - await expect(baseCollectionInstance.create(null)).to.be.rejectedWith('Failed to create [mockentitymodel]'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - - it('creates a new entity successfully', async () => { - mockElectroService.entities.mockentitymodel.create.returns( - { go: () => Promise.resolve(mockEntityModel) }, - ); - - const result = await baseCollectionInstance.create(mockRecord); - expect(result).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.create.calledOnce).to.be.true; - }); - - it('logs an error and throws when creation fails', async () => { - const error = new Error('Create failed'); - mockElectroService.entities.mockentitymodel.create.returns( - { go: () => Promise.reject(error) }, - ); - - await expect(baseCollectionInstance.create(mockRecord.data)).to.be.rejectedWith('Create failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('createMany', () => { - it('throws an error if the records are empty', async () => { - await expect(baseCollectionInstance.createMany(null)) - .to.be.rejectedWith('Failed to create many [mockentitymodel]: items must be a non-empty array'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - - it('creates multiple entities successfully', async () => { - const mockRecords = [mockRecord, mockRecord]; - const mockPutResults = { - type: 'query', - method: 'batchWrite', - params: { - RequestItems: { - mockentitymodel: [ - { PutRequest: { Item: mockRecord } }, - { PutRequest: { Item: mockRecord } }, - ], - }, - }, - }; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: (options) => { - options.listeners[0](mockPutResults); - options.listeners[0]({ type: 'result' }); - options.listeners[0]({ type: 'query', method: 'ignore' }); - return Promise.resolve({ unprocessed: [] }); - }, - params: () => {}, - }, - ); - - const result = await baseCollectionInstance.createMany(mockRecords); - expect(result.createdItems).to.be.an('array').that.has.length(2); - expect(result.createdItems).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.put.calledThrice).to.be.true; - }); - - it('creates many with a parent entity', async () => { - const mockRecords = [mockRecord, mockRecord]; - const mockPutResults = { - type: 'query', - method: 'batchWrite', - params: { - RequestItems: { - mockentitymodel: [ - { PutRequest: { Item: mockRecord } }, - { PutRequest: { Item: mockRecord } }, - ], - }, - }, - }; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: (options) => { - options.listeners[0](mockPutResults); - options.listeners[0]({ type: 'result' }); - options.listeners[0]({ type: 'query', method: 'ignore' }); - return Promise.resolve({ unprocessed: [] }); - }, - params: () => {}, - }, - ); - - const result = await baseCollectionInstance.createMany( - mockRecords, - { entity: { model: { name: 'mockentitymodel' } } }, - ); - expect(result.createdItems).to.be.an('array').that.has.length(2); - expect(result.createdItems).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.put.calledThrice).to.be.true; - }); - - it('creates some entities successfully with unprocessed items', async () => { - const mockRecords = [mockRecord, mockRecord]; - const mockPutResults = { - type: 'query', - method: 'batchWrite', - params: { - RequestItems: { - mockentitymodel: [ - { PutRequest: { Item: mockRecord } }, - ], - }, - }, - }; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: (options) => { - options.listeners[0](mockPutResults); - return Promise.resolve({ unprocessed: [mockRecord] }); - }, - params: () => {}, - }, - ); - - const result = await baseCollectionInstance.createMany(mockRecords); - expect(result.createdItems).to.be.an('array').that.has.length(1); - expect(result.createdItems).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.put.calledThrice).to.be.true; - expect(mockLogger.error.calledOnceWith(`Failed to process all items in batch write for [mockentitymodel]: ${JSON.stringify([mockRecord])}`)).to.be.true; - }); - - it('fails creating some items due to ValidationError', async () => { - const error = new ElectroValidationError('Validation failed'); - mockElectroService.entities.mockentitymodel.put.returns( - { params: () => { throw error; } }, - ); - - const result = await baseCollectionInstance.createMany([mockRecord]); - expect(result.createdItems).to.be.an('array').that.has.length(0); - expect(result.errorItems).to.be.an('array').that.has.length(1); - expect(result.errorItems[0].item).to.deep.include(mockRecord); - }); - - it('logs an error and throws when creation fails', async () => { - const error = new Error('Create failed'); - const mockRecords = [mockRecord, mockRecord]; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: () => Promise.reject(error), - params: () => {}, - }, - ); - - await expect(baseCollectionInstance.createMany(mockRecords)).to.be.rejectedWith('Create failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('_saveMany', () => { /* eslint-disable no-underscore-dangle */ - it('throws an error if the records are empty', async () => { - await expect(baseCollectionInstance._saveMany(null)) - .to.be.rejectedWith('Failed to save many [mockentitymodel]: items must be a non-empty array'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - - it('saves multiple entities successfully', async () => { - const mockRecords = [mockRecord, mockRecord]; - mockElectroService.entities.mockentitymodel.put.returns({ go: () => [] }); - - const result = await baseCollectionInstance._saveMany(mockRecords); - expect(result).to.be.undefined; - expect(mockElectroService.entities.mockentitymodel.put.calledOnce).to.be.true; - }); - - it('saves some entities successfully with unprocessed items', async () => { - const mockRecords = [mockRecord, mockRecord]; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: () => Promise.resolve({ unprocessed: [mockRecord] }), - }, - ); - - const result = await baseCollectionInstance._saveMany(mockRecords); - expect(result).to.be.undefined; - expect(mockElectroService.entities.mockentitymodel.put.calledOnce).to.be.true; - expect(mockLogger.error.calledOnceWith(`Failed to process all items in batch write for [mockentitymodel]: ${JSON.stringify([mockRecord])}`)).to.be.true; - }); - - it('throws error and logs when save fails', async () => { - const error = new Error('Save failed'); - const mockRecords = [mockRecord, mockRecord]; - mockElectroService.entities.mockentitymodel.put.returns( - { go: () => Promise.reject(error) }, - ); - - await expect(baseCollectionInstance._saveMany(mockRecords)).to.be.rejectedWith('Save failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base.model.test.js deleted file mode 100755 index 3eebee30..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/base.model.test.js +++ /dev/null @@ -1,179 +0,0 @@ -/* - * 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 { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import BaseModel from '../../../../src/v2/models/base.model.js'; -import OpportunitySchema from '../../../../src/v2/schema/opportunity.schema.js'; - -chaiUse(chaiAsPromised); - -const opportunityEntity = new Entity(OpportunitySchema); - -describe('BaseModel', () => { - let mockElectroService; - let baseModelInstance; - let mockLogger; - let mockModelFactory; - - const mockRecord = { - basemodelId: '12345', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - beforeEach(() => { - mockLogger = { - error: spy(), - }; - - mockModelFactory = { - getCollection: stub(), - }; - - mockElectroService = { - entities: { - basemodel: { - name: 'basemodel', - model: { - name: 'basemodel', - schema: opportunityEntity.model.schema, - original: { - references: { - has_one: [], - has_many: [ - { type: 'has_many', target: 'Suggestions' }, - ], - belongs_to: [], - }, - }, - }, - remove: stub(), - patch: stub(), - }, - }, - }; - - baseModelInstance = new BaseModel(mockElectroService, mockModelFactory, mockRecord, mockLogger); - }); - - describe('base', () => { - it('creates a new instance of BaseModel', () => { - expect(baseModelInstance).to.be.an.instanceOf(BaseModel); - }); - - it('returns when initializeAttributes has no attributes', () => { - mockElectroService.entities.basemodel.model.schema.attributes = {}; - const instance = new BaseModel(mockElectroService, mockModelFactory, {}, mockLogger); - expect(instance).to.be.an.instanceOf(BaseModel); - }); - }); - - describe('getId', () => { - it('returns the ID of the entity', () => { - const id = baseModelInstance.getId(); - expect(id).to.equal('12345'); - }); - }); - - describe('getCreatedAt', () => { - it('returns the creation timestamp in ISO format', () => { - const createdAt = baseModelInstance.getCreatedAt(); - expect(createdAt).to.equal(mockRecord.createdAt); - }); - }); - - describe('getUpdatedAt', () => { - it('returns the updated timestamp in ISO format', () => { - const updatedAt = baseModelInstance.getUpdatedAt(); - expect(updatedAt).to.equal(mockRecord.updatedAt); - }); - }); - - describe('remove', () => { - it('removes the record and returns the current instance', async () => { - mockElectroService.entities.basemodel.remove.returns({ go: () => Promise.resolve() }); - await expect(baseModelInstance.remove()).to.eventually.equal(baseModelInstance); - expect(mockElectroService.entities.basemodel.remove.calledOnce).to.be.true; - expect(mockLogger.error.notCalled).to.be.true; - }); - - it('logs an error and throws when remove fails', async () => { - const error = new Error('Remove failed'); - mockElectroService.entities.basemodel.remove.returns({ go: () => Promise.reject(error) }); - - await expect(baseModelInstance.remove()).to.be.rejectedWith('Remove failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('save', () => { - it('saves the record and returns the current instance', async () => { - baseModelInstance.patcher.save = stub().returns(Promise.resolve()); - await expect(baseModelInstance.save()).to.eventually.equal(baseModelInstance); - expect(baseModelInstance.patcher.save.calledOnce).to.be.true; - expect(mockLogger.error.notCalled).to.be.true; - }); - - it('logs an error and throws when save fails', async () => { - const error = new Error('Save failed'); - baseModelInstance.patcher.save = stub().returns(Promise.reject(error)); - - await expect(baseModelInstance.save()).to.be.rejectedWith('Save failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('_fetchReference', () => { /* eslint-disable no-underscore-dangle */ - it('returns a cached reference if it exists', async () => { - baseModelInstance._cacheReference('Foo', 'bar'); - const result = await baseModelInstance._fetchReference('has_many', 'Foo'); - expect(result).to.equal('bar'); - }); - - it('returns null if belongs_to id is not set', async () => { - const result = await baseModelInstance._fetchReference('belongs_to', 'Foo'); - expect(result).to.be.null; - }); - - it('returns undefined if the reference does not exist', async () => { - mockModelFactory.getCollection.returns({ findByIndexKeys: stub() }); - const result = await baseModelInstance._fetchReference('has_many', 'Foo'); - expect(result).to.be.undefined; - }); - - it('fetches a belongs_to reference by ID', async () => { - mockModelFactory.getCollection.returns({ findById: stub().returns('bar') }); - baseModelInstance.record.fooId = '12345'; - const result = await baseModelInstance._fetchReference('belongs_to', 'Foo'); - expect(result).to.equal('bar'); - }); - - it('fetches a has_one reference by ID', async () => { - mockModelFactory.getCollection.returns({ findById: stub().returns('bar') }); - baseModelInstance.record.fooId = '12345'; - const result = await baseModelInstance._fetchReference('has_one', 'Foo'); - expect(result).to.equal('bar'); - }); - - it('fetches a has_many reference by foreign key', async () => { - mockModelFactory.getCollection.returns({ findByIndexKeys: stub().returns(['bar']) }); - const result = await baseModelInstance._fetchReference('has_many', 'Foo'); - expect(result).to.deep.equal(['bar']); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.collection.test.js new file mode 100755 index 00000000..c2cef26c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.collection.test.js @@ -0,0 +1,651 @@ +/* + * 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 */ + +// eslint-disable-next-line max-classes-per-file +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { ElectroValidationError } from 'electrodb'; +import { spy, stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import BaseCollection from '../../../../../src/v2/models/base/base.collection.js'; +import Schema from '../../../../../src/v2/models/base/schema.js'; +import BaseModel from '../../../../../src/v2/models/base/base.model.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const MockModel = class MockEntityModel extends BaseModel {}; +const MockCollection = class MockEntityCollection extends BaseCollection {}; + +const createSchema = (service, indexes) => new Schema( + MockModel, + MockCollection, + { + serviceName: 'service', + schemaVersion: 1, + attributes: { + someKey: { type: 'string' }, + someOtherKey: { type: 'number' }, + }, + indexes, + references: [], + }, +); + +const createInstance = (service, registry, indexes, log) => { + const schema = createSchema(service, indexes); + return new BaseCollection( + service, + registry, + schema, + log, + ); +}; + +describe('BaseCollection', () => { + let baseCollectionInstance; + let mockElectroService; + let mockEntityRegistry; + let mockIndexes = { primary: {} }; + let mockLogger; + + const mockRecord = { + mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956', + mockParentEntityModelId: 'some-parent-id', + data: { + someKey: 'someValue', + }, + }; + + beforeEach(() => { + mockEntityRegistry = { + getCollection: stub(), + }; + + mockLogger = { + error: spy(), + info: spy(), + warn: spy(), + }; + + mockElectroService = { + entities: { + mockEntityModel: { + create: stub(), + delete: stub(), + get: stub(), + put: stub(), + query: { + all: stub().returns({ + between: stub().returns({ + go: () => ({ data: [] }), + }), + go: () => ({ data: [] }), + }), + bySomeKey: stub(), + primary: stub(), + }, + model: { + entity: 'MockEntityModel', + indexes: {}, + table: 'data', + original: {}, + schema: { + attributes: {}, + }, + }, + }, + }, + }; + + baseCollectionInstance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + }); + + describe('collection methods', () => { + it('does not create accessors for the primary index', () => { + mockIndexes = { primary: {} }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + expect(instance).to.not.have.property('allBy'); + expect(instance).to.not.have.property('findBy'); + }); + + it('creates accessors for partition key attributes', () => { + mockIndexes = { + bySomeKey: { pk: { facets: ['someKey'] } }, + }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + expect(instance).to.have.property('allBySomeKey'); + expect(instance).to.have.property('findBySomeKey'); + }); + + it('creates accessors for sort key attributes', () => { + mockIndexes = { + bySomeKey: { sk: { facets: ['someKey'] } }, + }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + expect(instance).to.have.property('allBySomeKey'); + expect(instance).to.have.property('findBySomeKey'); + }); + + it('creates accessors for partition and sort key attributes', () => { + mockIndexes = { + bySomeKey: { pk: { facets: ['someKey'] }, sk: { facets: ['someOtherKey'] } }, + }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + expect(instance).to.have.property('allBySomeKey'); + expect(instance).to.have.property('allBySomeKeyAndSomeOtherKey'); + expect(instance).to.have.property('findBySomeKey'); + expect(instance).to.have.property('findBySomeKeyAndSomeOtherKey'); + }); + + it('parses accessor arguments correctly', async () => { + mockElectroService.entities.mockEntityModel.query.bySomeKey.returns( + { go: () => Promise.resolve({ data: [] }) }, + ); + mockIndexes = { + bySomeKey: { pk: { facets: ['someKey'] }, sk: { facets: ['someOtherKey'] } }, + }; + + mockElectroService.entities.mockEntityModel.model.schema = { + attributes: { + someKey: { type: 'string' }, + someOtherKey: { type: 'number' }, + }, + }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + const someKey = 'someValue'; + const someOtherKey = 1; + const options = { order: 'desc' }; + + await instance.allBySomeKey(someKey); + await instance.findBySomeKey(someKey); + await instance.allBySomeKeyAndSomeOtherKey(someKey, someOtherKey); + await instance.findBySomeKeyAndSomeOtherKey(someKey, someOtherKey); + await instance.findBySomeKeyAndSomeOtherKey(someKey, someOtherKey, options); + + await expect(instance.allBySomeKey()).to.be.rejectedWith('someKey is required'); + await expect(instance.findBySomeKey()).to.be.rejectedWith('someKey is required'); + await expect(instance.allBySomeKeyAndSomeOtherKey(someKey)).to.be.rejectedWith('someOtherKey is required'); + await expect(instance.allBySomeKeyAndSomeOtherKey(someKey, '1')).to.be.rejectedWith('someOtherKey is required'); + await expect(instance.findBySomeKeyAndSomeOtherKey(someKey)).to.be.rejectedWith('someOtherKey is required'); + }); + }); + + describe('findById', () => { + it('returns the entity if found', async () => { + const mockFindResult = { data: mockRecord }; + mockElectroService.entities.mockEntityModel.get.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.findById('ef39921f-9a02-41db-b491-02c98987d956'); + + expect(result.record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.get.calledOnce).to.be.true; + }); + + it('returns null if the entity is not found', async () => { + mockElectroService.entities.mockEntityModel.get.returns( + { go: () => Promise.resolve(null) }, + ); + + const result = await baseCollectionInstance.findById('ef39921f-9a02-41db-b491-02c98987d956'); + + expect(result).to.be.null; + expect(mockElectroService.entities.mockEntityModel.get.calledOnce).to.be.true; + }); + }); + + describe('findByIndexKeys', () => { + it('throws error if keys is not provided', async () => { + await expect(baseCollectionInstance.findByIndexKeys()) + .to.be.rejectedWith('Failed to query [mockEntityModel]: keys are required'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('throws error if index is not found', async () => { + await expect(baseCollectionInstance.findByIndexKeys({ someKey: 'someValue' }, { index: 'none' })) + .to.be.rejectedWith('Failed to query [mockEntityModel]: index [none] not found'); + expect(mockLogger.error).to.have.been.calledOnce; + }); + }); + + describe('create', () => { + it('throws an error if the record is empty', async () => { + await expect(baseCollectionInstance.create(null)).to.be.rejectedWith('Failed to create [mockEntityModel]'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('creates a new entity successfully', async () => { + mockElectroService.entities.mockEntityModel.create.returns( + { go: () => Promise.resolve({ data: mockRecord }) }, + ); + + const result = await baseCollectionInstance.create(mockRecord); + expect(result.record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.create.calledOnce).to.be.true; + }); + + it('logs an error and throws when creation fails', async () => { + const error = new Error('Create failed'); + mockElectroService.entities.mockEntityModel.create.returns( + { go: () => Promise.reject(error) }, + ); + + await expect(baseCollectionInstance.create(mockRecord.data)).to.be.rejectedWith('Create failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('createMany', () => { + it('throws an error if the items are empty', async () => { + await expect(baseCollectionInstance.createMany(null)) + .to.be.rejectedWith('Failed to create many [mockEntityModel]: items must be a non-empty array'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('creates multiple entities successfully', async () => { + const mockRecords = [mockRecord, mockRecord]; + const mockPutResults = { + type: 'query', + method: 'batchWrite', + params: { + RequestItems: { + mockEntityModel: [ + { PutRequest: { Item: mockRecord } }, + { PutRequest: { Item: mockRecord } }, + ], + }, + }, + }; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve(mockPutResults), + params: () => ({ Item: { ...mockRecord } }), + }, + ); + + const result = await baseCollectionInstance.createMany(mockRecords); + expect(result.createdItems).to.be.an('array').that.has.length(2); + expect(result.createdItems[0].record).to.deep.include(mockRecord); + expect(result.createdItems[1].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.put.calledThrice).to.be.true; + }); + + it('creates many with a parent entity', async () => { + const mockRecords = [mockRecord, mockRecord]; + const mockPutResults = { + type: 'query', + method: 'batchWrite', + params: { + RequestItems: { + mockEntityModel: [ + { PutRequest: { Item: mockRecord } }, + { PutRequest: { Item: mockRecord } }, + ], + }, + }, + }; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve(mockPutResults), + params: () => ({ Item: { ...mockRecord } }), + }, + ); + + const parent = { + record: { mockParentEntityModelId: mockRecord.mockParentEntityModelId }, + entityName: 'mockParentEntityModel', + entity: { model: { name: 'mockParentEntityModel' } }, + schema: { getModelName: () => 'MockParentEntityModel' }, + }; + + const result = await baseCollectionInstance.createMany(mockRecords, parent); + + expect(result.createdItems).to.be.an('array').that.has.length(2); + expect(result.createdItems[0].record).to.deep.include(mockRecord); + expect(result.createdItems[1].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.put.calledThrice).to.be.true; + expect(mockLogger.warn).to.not.have.been.called; + }); + + it('logs warning if parent is invalid', async () => { + const mockRecords = [mockRecord, mockRecord]; + const mockPutResults = { + type: 'query', + method: 'batchWrite', + params: { + RequestItems: { + mockEntityModel: [ + { PutRequest: { Item: mockRecord } }, + { PutRequest: { Item: mockRecord } }, + ], + }, + }, + }; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve(mockPutResults), + params: () => ({ Item: { ...mockRecord } }), + }, + ); + + const idNotMatchingParent = { + record: { mockParentEntityModelId: 'invalid-id' }, + entityName: 'mockParentEntityModel', + entity: { model: { name: 'mockParentEntityModel' } }, + }; + + const noEntityParent = { + record: { mockParentEntityModelId: 'invalid-id' }, + entity: { model: { name: 'mockParentEntityModel' } }, + }; + + const r1 = await baseCollectionInstance.createMany(mockRecords, idNotMatchingParent); + const r2 = await baseCollectionInstance.createMany(mockRecords, noEntityParent); + + expect(r1.createdItems).to.be.an('array').that.has.length(2); + expect(r1.createdItems[0].record).to.deep.include(mockRecord); + expect(r1.createdItems[1].record).to.deep.include(mockRecord); + + expect(r2.createdItems).to.be.an('array').that.has.length(2); + expect(r2.createdItems[0].record).to.deep.include(mockRecord); + expect(r2.createdItems[1].record).to.deep.include(mockRecord); + + expect(mockElectroService.entities.mockEntityModel.put).to.have.callCount(6); + expect(mockLogger.warn).to.have.callCount(4); + }); + + it('creates some entities successfully with unprocessed items', async () => { + const mockRecords = [mockRecord, mockRecord]; + let itemCount = 0; + + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve({ unprocessed: [mockRecord] }), + params: () => { + if (itemCount === 0) { + itemCount += 1; + return { Item: { ...mockRecord } }; + } else { + throw new ElectroValidationError('Validation failed'); + } + }, + }, + ); + + const result = await baseCollectionInstance.createMany(mockRecords); + expect(result.createdItems).to.be.an('array').that.has.length(1); + expect(result.createdItems[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.put.calledThrice).to.be.true; + expect(mockLogger.error.calledOnceWith(`Failed to process all items in batch write for [mockEntityModel]: ${JSON.stringify([mockRecord])}`)).to.be.true; + }); + + it('fails creating some items due to ValidationError', async () => { + const error = new ElectroValidationError('Validation failed'); + mockElectroService.entities.mockEntityModel.put.returns( + { params: () => { throw error; } }, + ); + + const result = await baseCollectionInstance.createMany([mockRecord]); + expect(result.createdItems).to.be.an('array').that.has.length(0); + expect(result.errorItems).to.be.an('array').that.has.length(1); + expect(result.errorItems[0].item).to.deep.include(mockRecord); + }); + + it('logs an error and throws when creation fails', async () => { + const error = new Error('Create failed'); + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.reject(error), + params: () => ({ Item: { ...mockRecord } }), + }, + ); + + await expect(baseCollectionInstance.createMany(mockRecords)).to.be.rejectedWith('Create failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('_saveMany', () => { /* eslint-disable no-underscore-dangle */ + it('throws an error if the records are empty', async () => { + await expect(baseCollectionInstance._saveMany(null)) + .to.be.rejectedWith('Failed to save many [mockEntityModel]: items must be a non-empty array'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('saves multiple entities successfully', async () => { + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockEntityModel.put.returns({ go: () => [] }); + + const result = await baseCollectionInstance._saveMany(mockRecords); + expect(result).to.be.undefined; + expect(mockElectroService.entities.mockEntityModel.put.calledOnce).to.be.true; + }); + + it('saves some entities successfully with unprocessed items', async () => { + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve({ unprocessed: [mockRecord] }), + }, + ); + + const result = await baseCollectionInstance._saveMany(mockRecords); + expect(result).to.be.undefined; + expect(mockElectroService.entities.mockEntityModel.put.calledOnce).to.be.true; + expect(mockLogger.error.calledOnceWith(`Failed to process all items in batch write for [mockEntityModel]: ${JSON.stringify([mockRecord])}`)).to.be.true; + }); + + it('throws error and logs when save fails', async () => { + const error = new Error('Save failed'); + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockEntityModel.put.returns( + { go: () => Promise.reject(error) }, + ); + + await expect(baseCollectionInstance._saveMany(mockRecords)).to.be.rejectedWith('Save failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('all', () => { + it('returns all entities successfully', async () => { + const mockFindResult = { data: [mockRecord] }; + mockElectroService.entities.mockEntityModel.query.all.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.all(); + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.all) + .to.have.been.calledOnceWithExactly({ pk: 'ALL_MOCKENTITYMODELS' }); + }); + + it('applies between filter if provided', async () => { + const mockFindResult = { data: [mockRecord] }; + const mockGo = stub().resolves(mockFindResult); + const mockBetween = stub().returns({ go: mockGo }); + mockElectroService.entities.mockEntityModel.query.all().between = mockBetween; + + const result = await baseCollectionInstance.all( + {}, + { between: { attribute: 'test', start: 'a', end: 'b' } }, + ); + + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockBetween).to.have.been.calledOnceWithExactly({ test: 'a' }, { test: 'b' }); + expect(mockGo).to.have.been.calledOnceWithExactly({ order: 'desc' }); + }); + + it('applies attribute filter if provided', async () => { + const mockFindResult = { data: [mockRecord] }; + const mockGo = stub().resolves(mockFindResult); + mockElectroService.entities.mockEntityModel.query.all.returns( + { go: mockGo }, + ); + + const result = await baseCollectionInstance.all({}, { attributes: ['test'] }); + + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.all) + .to.have.been.calledOnceWithExactly({ pk: 'ALL_MOCKENTITYMODELS' }); + expect(mockGo).to.have.been.calledOnceWithExactly({ order: 'desc', attributes: ['test'] }); + }); + }); + + describe('allByIndexKeys', () => { + it('throws error if keys is not provided', async () => { + await expect(baseCollectionInstance.allByIndexKeys()) + .to.be.rejectedWith('Failed to query [mockEntityModel]: keys are required'); + expect(mockLogger.error).to.have.been.calledOnce; + }); + + it('throws and error if options is not an object', async () => { + await expect(baseCollectionInstance.allByIndexKeys({ someKey: 'someValue' }, null)) + .to.be.rejectedWith('Failed to query [mockEntityModel]: options must be an object'); + expect(mockLogger.error).to.have.been.calledOnce; + }); + + it('successfully queries entities by index keys', async () => { + const mockFindResult = { data: [mockRecord] }; + mockElectroService.entities.mockEntityModel.query.bySomeKey.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.allByIndexKeys({ someKey: 'someValue' }); + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.bySomeKey) + .to.have.been.calledOnceWithExactly({ someKey: 'someValue' }); + }); + + it('successfully queries entities by primary index keys', async () => { + const mockFindResult = { data: [mockRecord] }; + delete mockElectroService.entities.mockEntityModel.query.all; + delete mockElectroService.entities.mockEntityModel.query.bySomeKey; + + mockElectroService.entities.mockEntityModel.query.primary.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.allByIndexKeys({ someKey: 'someValue' }); + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.primary) + .to.have.been.calledOnceWithExactly({ someKey: 'someValue' }); + }); + }); + + describe('findByAll', () => { + it('throws an error if sortKeys is not an object', async () => { + await expect(baseCollectionInstance.findByAll(null)) + .to.be.rejectedWith('Failed to find by all [mockEntityModel]: sort keys must be an object'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('finds all entities successfully', async () => { + const mockFindResult = { data: [mockRecord] }; + mockElectroService.entities.mockEntityModel.query.all.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.findByAll({ someKey: 'someValue' }); + expect(result.record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.all) + .to.have.been.calledOnceWithExactly( + { pk: 'ALL_MOCKENTITYMODELS', someKey: 'someValue' }, + ); + }); + + it('returns null if the entity is not found', async () => { + const result = await baseCollectionInstance.findByAll({ someKey: 'someValue' }); + expect(result).to.be.null; + expect(mockElectroService.entities.mockEntityModel.query.all) + .to.have.been.calledOnceWithExactly( + { pk: 'ALL_MOCKENTITYMODELS', someKey: 'someValue' }, + ); + }); + }); + + describe('removeByIds', () => { + it('throws an error if the ids are not an array', async () => { + await expect(baseCollectionInstance.removeByIds(null)) + .to.be.rejectedWith('Failed to remove [mockEntityModel]: ids must be a non-empty array'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('throws an error if the ids are empty', async () => { + await expect(baseCollectionInstance.removeByIds([])) + .to.be.rejectedWith('Failed to remove [mockEntityModel]: ids must be a non-empty array'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('removes entities successfully', async () => { + const mockIds = ['ef39921f-9a02-41db-b491-02c98987d956', 'ef39921f-9a02-41db-b491-02c98987d957']; + mockElectroService.entities.mockEntityModel.delete.returns({ go: () => Promise.resolve() }); + await baseCollectionInstance.removeByIds(mockIds); + expect(mockElectroService.entities.mockEntityModel.delete) + .to.have.been.calledOnceWithExactly([ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ]); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.model.test.js new file mode 100755 index 00000000..1c6be4e6 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.model.test.js @@ -0,0 +1,309 @@ +/* + * 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 { Entity } from 'electrodb'; +import { spy, stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import BaseModel from '../../../../../src/v2/models/base/base.model.js'; +import KeyEventSchema from '../../../../../src/v2/models/key-event/key-event.schema.js'; +import OpportunitySchema from '../../../../../src/v2/models/opportunity/opportunity.schema.js'; +import SuggestionSchema from '../../../../../src/v2/models/suggestion/suggestion.schema.js'; +import Reference from '../../../../../src/v2/models/base/reference.js'; +import BaseCollection from '../../../../../src/v2/models/base/base.collection.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const opportunityEntity = new Entity(OpportunitySchema.toElectroDBSchema()); +const suggestionEntity = new Entity(SuggestionSchema.toElectroDBSchema()); +const MockCollection = class MockCollection extends BaseCollection {}; + +describe('BaseModel', () => { + let mockElectroService; + let baseModelInstance; + let mockLogger; + let mockEntityRegistry; + + const mockRecord = { + opportunityId: '12345', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + beforeEach(() => { + mockLogger = { + debug: spy(), + error: spy(), + info: spy(), + warn: spy(), + }; + + mockEntityRegistry = { + log: mockLogger, + getCollection: stub().returns({ + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: stub().returns('Opportunity'), + }, + }), + }; + + mockEntityRegistry.getCollection.withArgs('OpportunityCollection').returns({ + log: mockLogger, + findByIndexKeys: stub().resolves({}), + allByIndexKeys: stub().resolves([]), + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: stub().returns('Opportunity'), + }, + }); + + mockElectroService = { + entities: { + opportunity: { + entity: opportunityEntity, + remove: stub().returns({ go: stub().resolves() }), + }, + suggestion: { + entity: suggestionEntity, + query: { primary: stub().returns({ go: stub().resolves({ data: [mockRecord] }) }) }, + remove: stub().returns({ go: stub().resolves() }), + indexes: { + primary: {}, + }, + }, + }, + }; + + const SuggestionCollection = new MockCollection( + mockElectroService, + mockEntityRegistry, + SuggestionSchema, + mockLogger, + ); + + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(SuggestionCollection); + + baseModelInstance = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + mockRecord, + mockLogger, + ); + }); + + describe('base', () => { + it('creates a new instance of BaseModel', () => { + expect(baseModelInstance).to.be.an.instanceOf(BaseModel); + }); + + it('returns when initializeAttributes has no attributes', () => { + const originalAttributes = { ...OpportunitySchema.attributes }; + OpportunitySchema.attributes = {}; + + const instance = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + {}, + mockLogger, + ); + + expect(instance).to.be.an.instanceOf(BaseModel); + + OpportunitySchema.attributes = originalAttributes; + }); + }); + + describe('getId', () => { + it('returns the ID of the entity', () => { + const id = baseModelInstance.getId(); + expect(id).to.equal('12345'); + }); + }); + + describe('getCreatedAt', () => { + it('returns the creation timestamp in ISO format', () => { + const createdAt = baseModelInstance.getCreatedAt(); + expect(createdAt).to.equal(mockRecord.createdAt); + }); + }); + + describe('getUpdatedAt', () => { + it('returns the updated timestamp in ISO format', () => { + const updatedAt = baseModelInstance.getUpdatedAt(); + expect(updatedAt).to.equal(mockRecord.updatedAt); + }); + }); + + describe('remove', () => { + let dependent; + let dependents; + let schema; + let originalReferences = []; + + beforeEach(() => { + dependent = { remove: stub().resolves() }; + dependents = [dependent, dependent, dependent]; + originalReferences = [...OpportunitySchema.references]; + schema = OpportunitySchema; + + const collectionMethods = { + findByIndexKeys: stub().resolves(dependent), + allByIndexKeys: stub().resolves(dependents), + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + }, + }; + + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(collectionMethods); + mockEntityRegistry.getCollection.withArgs('SomeModelCollection').returns(collectionMethods); + mockElectroService.entities.opportunity.remove.returns({ go: () => Promise.resolve() }); + }); + + afterEach(() => { + OpportunitySchema.references = originalReferences; + }); + + it('removes the record and returns the current instance', async () => { + await expect(baseModelInstance.remove()).to.eventually.equal(baseModelInstance); + + expect(mockElectroService.entities.opportunity.remove.calledOnce).to.be.true; + expect(mockLogger.error.notCalled).to.be.true; + }); + + it('removes record with dependents', async () => { + const reference = Reference.fromJSON({ + type: Reference.TYPES.HAS_ONE, + target: 'SomeModel', + options: { removeDependents: true }, + }); + + baseModelInstance.getSomeModel = stub().resolves(dependent); + baseModelInstance.getSuggestions = stub().resolves(dependents); + + schema.references.push(reference); + + await expect(baseModelInstance.remove()).to.eventually.equal(baseModelInstance); + + // self remove + expect(mockElectroService.entities.opportunity.remove.calledOnce).to.be.true; + // dependents remove: 3 = has_many, 1 = has_one + expect(dependent.remove).to.have.callCount(4); + expect(baseModelInstance.getSomeModel).to.have.been.calledOnce; + expect(mockLogger.error).to.not.have.been.called; + }); + + it('does not remove dependents if there aren\'t any', async () => { + schema.references = []; + + await expect(baseModelInstance.remove()).to.eventually.equal(baseModelInstance); + + expect(dependent.remove.notCalled).to.be.true; + }); + + it('does not remove dependents if none are found', async () => { + schema.references[0].options.removeDependents = true; + schema.references[1].options.removeDependents = true; + mockEntityRegistry.getCollection = () => ({ + allByIndexKeys: stub().resolves([]), + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: stub().returns('SomeModel'), + }, + }); + + const instance = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + mockRecord, + mockLogger, + ); + + await expect(instance.remove()).to.eventually.equal(instance); + + expect(dependent.remove.notCalled).to.be.true; + }); + + it('logs an error and throws when remove fails', async () => { + const error = new Error('Remove failed'); + mockElectroService.entities.opportunity.remove.returns({ go: () => Promise.reject(error) }); + + await expect(baseModelInstance.remove()).to.be.rejectedWith('Remove failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('save', () => { + it('saves the record and returns the current instance', async () => { + baseModelInstance.patcher.save = stub().returns(Promise.resolve()); + await expect(baseModelInstance.save()).to.eventually.equal(baseModelInstance); + expect(baseModelInstance.patcher.save.calledOnce).to.be.true; + expect(mockLogger.error.notCalled).to.be.true; + }); + + it('logs an error and throws when save fails', async () => { + const error = new Error('Save failed'); + baseModelInstance.patcher.save = stub().returns(Promise.reject(error)); + + await expect(baseModelInstance.save()).to.be.rejectedWith('Save failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('references', () => { /* eslint-disable no-underscore-dangle */ + describe('reciprocal', () => { + it('logs a warning if reference is not found', async () => { + mockEntityRegistry.getCollection.withArgs('FooCollection').returns(new MockCollection( + mockElectroService, + mockEntityRegistry, + KeyEventSchema, + mockLogger, + )); + OpportunitySchema.references.push(new Reference('has_many', 'Foos')); + + const result = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + mockRecord, + mockLogger, + ); + + expect(result).to.be.an.instanceOf(BaseModel); + expect(mockLogger.warn).to.have.been.calledOnceWithExactly('Reciprocal reference not found for Opportunity to Foos'); + }); + + it('logs a debug message if reference sort keys are empty', async () => { + SuggestionSchema.references = [new Reference('belongs_to', 'Opportunity', { sortKeys: [] })]; + + const result = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + mockRecord, + mockLogger, + ); + + expect(result).to.be.an.instanceOf(BaseModel); + expect(mockLogger.debug).to.have.been.calledOnceWithExactly('No sort keys defined for Opportunity to Suggestions'); + }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/entity.registry.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/entity.registry.test.js new file mode 100644 index 00000000..bf7c5437 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/entity.registry.test.js @@ -0,0 +1,114 @@ +/* + * 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 */ + +// eslint-disable-next-line max-classes-per-file +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import EntityRegistry from '../../../../../src/v2/models/base/entity.registry.js'; +import { BaseCollection, BaseModel } from '../../../../../src/index.js'; +import Schema from '../../../../../src/v2/models/base/schema.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('EntityRegistry', () => { + const MockModel = class MockModel extends BaseModel {}; + const MockCollection = class MockCollection extends BaseCollection {}; + const MockSchema = new Schema( + MockModel, + MockCollection, + { + attributes: { test: {} }, + indexes: { test: {} }, + serviceName: 'SpaceDog', + schemaVersion: 1, + references: [], + }, + ); + + let electroService; + let entityRegistry; + let originalEntities; + + beforeEach(() => { + originalEntities = { ...EntityRegistry.entities }; + EntityRegistry.entities = {}; + + electroService = { + entities: { + mockModel: { + model: { + name: 'test', + indexes: [], + schema: {}, + original: { + references: {}, + }, + }, + }, + }, + }; + + EntityRegistry.registerEntity(MockSchema, MockCollection); + + entityRegistry = new EntityRegistry(electroService, console); + }); + + afterEach(() => { + EntityRegistry.entities = originalEntities; + }); + + it('gets collection by collection name', () => { + const collection = entityRegistry.getCollection('MockCollection'); + + expect(collection).to.be.an.instanceOf(BaseCollection); + }); + + it('throws error when getting a non-existing collection', () => { + expect(() => entityRegistry.getCollection('NonExistentCollection')) + .to.throw('Collection NonExistentCollection not found'); + }); + + it('gets all collections', () => { + const collections = entityRegistry.getCollections(); + + expect(collections).to.be.an('object'); + expect(Object.keys(collections)).to.have.lengthOf(1); + expect(collections.Mock).to.be.an.instanceOf(MockCollection); + }); + + it('gets all entities', () => { + const entities = EntityRegistry.getEntities(); + + expect(entities).to.be.an('object'); + expect(Object.keys(entities)).to.have.lengthOf(1); + expect(entities).to.deep.equal({ + mockModel: { + attributes: { + test: {}, + }, + indexes: { + test: {}, + }, + model: { + entity: 'MockModel', + service: 'SpaceDog', + version: '1', + }, + }, + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/reference.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/reference.test.js new file mode 100644 index 00000000..a618c26d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/reference.test.js @@ -0,0 +1,312 @@ +/* + * 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. + */ + +/* + * 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 { stub } from 'sinon'; +import Reference from '../../../../../src/v2/models/base/reference.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('Reference', () => { + let mockLogger; + + beforeEach(() => { + mockLogger = { + debug: stub(), + error: stub(), + warn: stub(), + }; + }); + + describe('constructor', () => { + it('creates a new reference with the correct properties', () => { + const reference = new Reference('has_many', 'Test'); + + expect(reference).to.be.an('object'); + expect(reference).to.deep.equal({ + options: {}, + target: 'Test', + type: 'has_many', + }); + }); + + it('creates a new reference from JSON', () => { + const reference = Reference.fromJSON({ + options: {}, + target: 'Test', + type: 'has_many', + }); + + expect(reference).to.be.an('object'); + expect(reference).to.deep.equal({ + options: {}, + target: 'Test', + type: 'has_many', + }); + }); + + it('throws an error for an invalid type', () => { + expect(() => new Reference('invalid', 'Test')).to.throw('Invalid reference type: invalid'); + }); + + it('throws an error for an invalid target', () => { + expect(() => new Reference('has_many', '')).to.throw('Invalid target'); + }); + }); + + describe('isValidType', () => { + it('returns true for a valid type', () => { + expect(Reference.isValidType('has_many')).to.be.true; + }); + + it('returns false for an invalid type', () => { + expect(Reference.isValidType('invalid')).to.be.false; + }); + }); + + describe('accessors', () => { + it('returns the target', () => { + const reference = new Reference('has_many', 'Test'); + + expect(reference.getTarget()).to.equal('Test'); + }); + + it('returns the type', () => { + const reference = new Reference('has_many', 'Test'); + + expect(reference.getType()).to.equal('has_many'); + }); + + it('returns true for removeDependents', () => { + const reference = new Reference('has_many', 'Test', { removeDependents: true }); + + expect(reference.isRemoveDependents()).to.be.true; + }); + + it('returns false for removeDependents', () => { + const reference = new Reference('has_many', 'Test', { removeDependents: false }); + + expect(reference.isRemoveDependents()).to.be.false; + }); + }); + + describe('toAccessorConfigs', () => { + it('returns accessor configs for has_many', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(new Reference('belongs_to', 'Test')), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('has_many', 'Test'); + const entity = { + entityName: 'Test', + getId: () => '123', + schema, + }; + + const accessorConfigs = reference.toAccessorConfigs(registry, entity); + + expect(accessorConfigs).to.be.an('array'); + expect(accessorConfigs).to.have.lengthOf(1); + expect(accessorConfigs[0]).to.deep.equal({ + all: true, + collection: { + name: 'TestCollection', + schema, + }, + context: { + entityName: 'Test', + getId: entity.getId, + schema, + }, + foreignKey: { + name: 'testId', + value: '123', + }, + name: 'getTests', + requiredKeys: [], + }); + }); + + it('returns accessor configs for has_one', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(new Reference('belongs_to', 'Test')), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('has_one', 'Test'); + const entity = { + entityName: 'Test', + getId: () => '123', + schema, + }; + + const accessorConfigs = reference.toAccessorConfigs(registry, entity); + + expect(accessorConfigs).to.be.an('array'); + expect(accessorConfigs).to.have.lengthOf(1); + expect(accessorConfigs[0]).to.deep.equal({ + collection: { + name: 'TestCollection', + schema, + }, + context: { + entityName: 'Test', + getId: entity.getId, + schema, + }, + foreignKey: { + name: 'testId', + value: '123', + }, + name: 'getTest', + requiredKeys: [], + }); + }); + + it('returns accessor configs for belongs_to', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(new Reference('belongs_to', 'Test')), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('belongs_to', 'Test'); + const entity = { + entityName: 'Test', + record: { testId: '123' }, + schema, + }; + + const accessorConfigs = reference.toAccessorConfigs(registry, entity); + + expect(accessorConfigs).to.be.an('array'); + expect(accessorConfigs).to.have.lengthOf(1); + expect(accessorConfigs[0]).to.deep.equal({ + collection: { + name: 'TestCollection', + schema, + }, + context: { + entityName: 'Test', + record: { testId: '123' }, + schema, + }, + foreignKey: { + name: 'testId', + value: '123', + }, + byId: true, + name: 'getTest', + requiredKeys: [], + }); + }); + + it('logs warning for missing reciprocal reference', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('has_many', 'Test'); + const entity = { + entityName: 'Test', + getId: () => '123', + schema, + }; + + reference.toAccessorConfigs(registry, entity); + + expect(mockLogger.warn).to.have.been.calledOnceWithExactly('Reciprocal reference not found for Test to Test'); + }); + + it('logs debug for no sort keys defined', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(new Reference('belongs_to', 'Test')), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('has_many', 'Test'); + const entity = { + entityName: 'Test', + getId: () => '123', + schema, + }; + + reference.toAccessorConfigs(registry, entity); + + expect(mockLogger.debug).to.have.been.calledOnceWithExactly('No sort keys defined for Test to Test'); + }); + + it('throws an error for an invalid type', () => { + const reference = new Reference('has_many', 'Test'); + reference.type = 'invalid'; + + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema: {}, + }), + }; + + expect(() => reference.toAccessorConfigs(registry, { })).to.throw('Unsupported reference type: invalid'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.builder.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.builder.test.js new file mode 100755 index 00000000..76b63c2b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.builder.test.js @@ -0,0 +1,475 @@ +/* + * 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 */ + +// eslint-disable-next-line max-classes-per-file +import { isIsoDate } from '@adobe/spacecat-shared-utils'; +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import { validate as uuidValidate } from 'uuid'; + +import SchemaBuilder from '../../../../../src/v2/models/base/schema.builder.js'; +import { BaseCollection, BaseModel } from '../../../../../src/index.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SchemaBuilder', () => { + const MockModel = class MockModel extends BaseModel {}; + const MockCollection = class MockCollection extends BaseCollection {}; + + let instance; + + beforeEach(() => { + instance = new SchemaBuilder(MockModel, MockCollection); + }); + + describe('constructor', () => { + it('throws error if invalid model class is provided', () => { + expect(() => new SchemaBuilder()) + .to.throw('modelClass must be a subclass of BaseModel.'); + expect(() => new SchemaBuilder(Number)) + .to.throw('modelClass must be a subclass of BaseModel.'); + }); + + it('throws error if invalid collection class is provided', () => { + expect(() => new SchemaBuilder(MockModel)) + .to.throw('collectionClass must be a subclass of BaseCollection.'); + expect(() => new SchemaBuilder(MockModel, Number)) + .to.throw('collectionClass must be a subclass of BaseCollection.'); + }); + + it('throws an error if version is not a positive integer', () => { + expect(() => new SchemaBuilder(MockModel, MockCollection, -1)) + .to.throw('schemaVersion is required and must be a positive integer.'); + expect(() => new SchemaBuilder(MockModel, MockCollection, '-1')) + .to.throw('schemaVersion is required and must be a positive integer.'); + expect(() => new SchemaBuilder(MockModel, MockCollection, 1.2)) + .to.throw('schemaVersion is required and must be a positive integer.'); + }); + + it('successfully creates an instance', () => { + expect(instance).to.be.an.instanceOf(SchemaBuilder); + expect(instance.entityName).to.equal('MockModel'); + expect(instance.serviceName).to.equal('SpaceCat'); + expect(instance.schemaVersion).to.equal(1); + expect(instance.indexes).to.deep.equal({}); + expect(instance.references).to.deep.equal([]); + expect(instance.attributes).to.deep.equal({ + mockModelId: { + default: instance.attributes.mockModelId.default, + type: 'string', + required: true, + readOnly: true, + validate: instance.attributes.mockModelId.validate, + }, + createdAt: { + default: instance.attributes.createdAt.default, + type: 'string', + readOnly: true, + required: true, + }, + updatedAt: { + default: instance.attributes.updatedAt.default, + type: 'string', + required: true, + readOnly: true, + watch: '*', + set: instance.attributes.updatedAt.set, + }, + }); + + expect(instance.rawIndexes).to.deep.equal({ + primary: { + pk: { composite: ['mockModelId'], field: 'pk' }, + sk: { composite: [], field: 'sk' }, + }, + all: null, + belongs_to: {}, + other: {}, + }); + }); + }); + + describe('addAttribute', () => { + it('throws error if attribute name is not provided', () => { + expect(() => instance.addAttribute()) + .to.throw('Attribute name is required and must be non-empty.'); + }); + + it('throws error if attribute definition is not provided', () => { + expect(() => instance.addAttribute('test')) + .to.throw('Attribute data for "test" is required and must be a non-empty object.'); + expect(() => instance.addAttribute('test', 'test')) + .to.throw('Attribute data for "test" is required and must be a non-empty object.'); + expect(() => instance.addAttribute('test', {})) + .to.throw('Attribute data for "test" is required and must be a non-empty object.'); + }); + + it('successfully adds an attribute', () => { + const result = instance.addAttribute('test', { + type: 'string', + required: true, + default: 'test', + validate: () => true, + }); + + expect(result).to.equal(instance); + expect(instance.attributes.test).to.deep.equal({ + type: 'string', + required: true, + default: 'test', + validate: instance.attributes.test.validate, + }); + }); + }); + + describe('addAllIndexWithComposite', () => { + it('throws error if attribute name is not provided', () => { + expect(() => instance.addAllIndexWithComposite()) + .to.throw('At least one composite attribute name is required.'); + }); + + it('successfully adds an all index', () => { + const result = instance.addAllIndexWithComposite('test'); + + expect(result).to.equal(instance); + expect(instance.rawIndexes.all).to.deep.equal({ + index: 'spacecat-data-MockModel-all', + pk: { field: 'gsi1pk', template: 'ALL_MOCKMODELS' }, + sk: { composite: ['test'], field: 'gsi1sk' }, + }); + }); + }); + + describe('addAllIndexWithTemplateField', () => { + it('throws error if field name is not provided', () => { + expect(() => instance.addAllIndexWithTemplateField()) + .to.throw('fieldName is required and must be a non-empty string.'); + }); + + it('throws error if template is not provided', () => { + expect(() => instance.addAllIndexWithTemplateField('test')) + .to.throw('template is required and must be a non-empty string.'); + }); + + it('successfully adds an all index', () => { /* eslint-disable no-template-curly-in-string */ + const result = instance.addAllIndexWithTemplateField('test', '${test}'); + + expect(result).to.equal(instance); + expect(instance.rawIndexes.all).to.deep.equal({ + index: 'spacecat-data-MockModel-all', + pk: { field: 'gsi1pk', template: 'ALL_MOCKMODELS' }, + sk: { field: 'test', template: '${test}' }, + }); + }); + }); + + describe('addIndex', () => { + it('throws error if index name is not provided', () => { + expect(() => instance.addIndex()) + .to.throw('Index name is required and must be a non-empty string.'); + }); + + it('throws error if index name is reserved', () => { + expect(() => instance.addIndex('all')) + .to.throw('Index name "all" is reserved.'); + expect(() => instance.addIndex('primary')) + .to.throw('Index name "primary" is reserved.'); + }); + + it('throws error if pk is not provided', () => { + expect(() => instance.addIndex('test')) + .to.throw('Partition key configuration (pk) is required and must be a non-empty object.'); + expect(() => instance.addIndex('test', 'pk')) + .to.throw('Partition key configuration (pk) is required and must be a non-empty object.'); + expect(() => instance.addIndex('test', {})) + .to.throw('Partition key configuration (pk) is required and must be a non-empty object.'); + }); + + it('throws error if sk is not provided', () => { + expect(() => instance.addIndex('test', { composite: ['test'] })) + .to.throw('Sort key configuration (sk) is required and must be a non-empty object.'); + expect(() => instance.addIndex('test', { composite: ['test'] }, 'sk')) + .to.throw('Sort key configuration (sk) is required and must be a non-empty object.'); + expect(() => instance.addIndex('test', { composite: ['test'] }, {})) + .to.throw('Sort key configuration (sk) is required and must be a non-empty object.'); + }); + + it('successfully adds an index', () => { + const result = instance.addIndex('test', { composite: ['test'] }, { composite: ['test'] }); + + expect(result).to.equal(instance); + expect(instance.rawIndexes.other.test).to.deep.equal({ + index: 'spacecat-data-MockModel-test', + pk: { composite: ['test'] }, + sk: { composite: ['test'] }, + }); + }); + }); + + describe('addReference', () => { + it('throws error if reference type is not provided', () => { + expect(() => instance.addReference()) + .to.throw('Invalid referenceType: "undefined"'); + }); + + it('throws error if reference type is invalid', () => { + expect(() => instance.addReference('test')) + .to.throw('Invalid referenceType: "test"'); + }); + + it('throws error if entity name is not provided', () => { + expect(() => instance.addReference('belongs_to')) + .to.throw('entityName for reference is required and must be a non-empty string.'); + }); + + it('successfully adds a has_many reference', () => { + const result = instance.addReference('has_many', 'SomeEntity'); + + expect(result).to.equal(instance); + expect(instance.references).to.be.an('array').with.length(1); + expect(instance.references[0]) + .to.deep.equal({ + options: { + removeDependents: false, + sortKeys: [], + }, + target: 'SomeEntity', + type: 'has_many', + }); + expect(instance.attributes).to.not.have.property('someEntityId'); + expect(instance.rawIndexes.belongs_to).to.not.have.property('bySomeEntityId'); + }); + + it('successfully adds a has_many reference with removeDependents', () => { + const result = instance.addReference('has_many', 'SomeEntity', [], { removeDependents: true }); + + expect(result).to.equal(instance); + expect(instance.references).to.be.an('array').with.length(1); + expect(instance.references[0]).to.deep.equal({ + options: { + removeDependents: true, + sortKeys: [], + }, + target: 'SomeEntity', + type: 'has_many', + }); + expect(instance.attributes).to.not.have.property('someEntityId'); + expect(instance.rawIndexes.belongs_to).to.not.have.property('bySomeEntityId'); + }); + + it('successfully adds a belongs_to reference', () => { + const result = instance.addReference('belongs_to', 'SomeEntity'); + + expect(result).to.equal(instance); + expect(instance.references).to.be.an('array').with.length(1); + expect(instance.references[0]).to.deep.equal({ + options: { + required: true, + sortKeys: [], + }, + target: 'SomeEntity', + type: 'belongs_to', + }); + expect(instance.attributes.someEntityId).to.deep.equal({ + required: true, + type: 'string', + validate: instance.attributes.someEntityId.validate, + }); + expect(instance.rawIndexes.belongs_to.bySomeEntityId).to.deep.equal({ + index: 'spacecat-data-MockModel-bySomeEntityId', + pk: { composite: ['someEntityId'] }, + sk: { composite: ['updatedAt'] }, + }); + }); + + it('successfully adds a belongs_to reference which is not required', () => { + const result = instance.addReference('belongs_to', 'someEntity', ['updatedAt'], { required: false }); + + expect(result).to.equal(instance); + expect(instance.references).to.be.an('array').with.length(1); + expect(instance.references[0]).to.deep.equal({ + options: { + required: false, + sortKeys: ['updatedAt'], + }, + target: 'someEntity', + type: 'belongs_to', + }); + expect(instance.attributes.someEntityId).to.deep.equal({ + required: false, + type: 'string', + validate: instance.attributes.someEntityId.validate, + }); + expect(instance.rawIndexes.belongs_to.bySomeEntityId).to.deep.equal({ + index: 'spacecat-data-MockModel-bySomeEntityId', + pk: { composite: ['someEntityId'] }, + sk: { composite: ['updatedAt'] }, + }); + }); + }); + + describe('validate, default, and set', () => { + it('sets defaults for createdAt and updatedAt', () => { + expect(isIsoDate(instance.attributes.createdAt.default())).to.be.true; + expect(isIsoDate(instance.attributes.updatedAt.default())).to.be.true; + expect(isIsoDate(instance.attributes.updatedAt.set())).to.be.true; + }); + + it('sets default for id attribute', () => { + expect(uuidValidate(instance.attributes.mockModelId.default())).to.be.true; + }); + + it('validates id attribute', () => { + expect(instance.attributes.mockModelId.validate('78fec9c7-2141-4600-b7b1-ea5c78752b91')).to.be.true; + expect(instance.attributes.mockModelId.validate('invalid')).to.be.false; + }); + + it('validates foreign key attribute', () => { + instance.addReference('belongs_to', 'someEntity'); + expect(instance.attributes.someEntityId.validate('78fec9c7-2141-4600-b7b1-ea5c78752b91')).to.be.true; + expect(instance.attributes.someEntityId.validate('invalid')).to.be.false; + }); + + it('validates non-required foreign key attribute', () => { + instance.addReference('belongs_to', 'someEntity', [], { required: false }); + expect(instance.attributes.someEntityId.required).to.be.false; + expect(instance.attributes.someEntityId.validate()).to.be.true; + expect(instance.attributes.someEntityId.validate('78fec9c7-2141-4600-b7b1-ea5c78752b91')).to.be.true; + expect(instance.attributes.someEntityId.validate('invalid')).to.be.false; + }); + }); + + describe('build', () => { + it('returns the built schema', () => { + instance.addReference('belongs_to', 'Organization'); + instance.addReference('belongs_to', 'Site', ['someField'], { required: false }); + instance.addReference('has_many', 'Audits'); + instance.addAttribute('baseURL', { + type: 'string', + required: true, + validate: () => true, + }); + instance.addAllIndexWithComposite('baseURL'); + instance.addAllIndexWithTemplateField('test', '${test}'); + instance.addIndex('byDeliveryType', { composite: ['deliveryType'] }, { composite: ['updatedAt'] }); + instance.addIndex('bySomeField', { field: 'someField', composite: ['deliveryType'] }, { composite: ['updatedAt'] }); + + const schema = instance.build(); + + expect(schema).to.deep.equal({ + schemaVersion: 1, + serviceName: 'SpaceCat', + modelClass: MockModel, + collectionClass: MockCollection, + attributes: { + mockModelId: { + type: 'string', + required: true, + readOnly: true, + validate: instance.attributes.mockModelId.validate, + default: instance.attributes.mockModelId.default, + }, + createdAt: { + type: 'string', + readOnly: true, + required: true, + default: instance.attributes.createdAt.default, + }, + updatedAt: { + type: 'string', + required: true, + readOnly: true, + watch: '*', + default: instance.attributes.updatedAt.default, + set: instance.attributes.updatedAt.set, + }, + organizationId: { + type: 'string', + required: true, + validate: instance.attributes.organizationId.validate, + }, + siteId: { + type: 'string', + required: false, + validate: instance.attributes.siteId.validate, + }, + baseURL: { + type: 'string', + required: true, + validate: instance.attributes.baseURL.validate, + }, + }, + indexes: { + primary: { + pk: { field: 'pk', composite: ['mockModelId'] }, + sk: { field: 'sk', composite: [] }, + }, + all: { + index: 'spacecat-data-MockModel-all', + pk: { field: 'gsi1pk', template: 'ALL_MOCKMODELS' }, + sk: { field: 'test', template: '${test}' }, + }, + byOrganizationId: { + index: 'spacecat-data-MockModel-byOrganizationId', + pk: { composite: ['organizationId'], field: 'gsi2pk' }, + sk: { composite: ['updatedAt'], field: 'gsi2sk' }, + }, + bySiteId: { + index: 'spacecat-data-MockModel-bySiteId', + pk: { composite: ['siteId'], field: 'gsi3pk' }, + sk: { composite: ['someField'], field: 'gsi3sk' }, + }, + byDeliveryType: { + index: 'spacecat-data-MockModel-byDeliveryType', + pk: { composite: ['deliveryType'], field: 'gsi4pk' }, + sk: { composite: ['updatedAt'], field: 'gsi4sk' }, + }, + bySomeField: { + index: 'spacecat-data-MockModel-bySomeField', + pk: { composite: ['deliveryType'], field: 'someField' }, + sk: { composite: ['updatedAt'], field: 'gsi5sk' }, + }, + }, + references: [ + { + options: { + required: true, + sortKeys: [], + }, + target: 'Organization', + type: 'belongs_to', + }, + { + options: { + required: false, + sortKeys: ['someField'], + }, + target: 'Site', + type: 'belongs_to', + }, + { + options: { + removeDependents: false, + sortKeys: [], + }, + target: 'Audits', + type: 'has_many', + }, + ], + }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.test.js new file mode 100644 index 00000000..eb3c643d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.test.js @@ -0,0 +1,229 @@ +/* + * 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. + */ + +/* + * 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 */ + +// eslint-disable-next-line max-classes-per-file +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import BaseModel from '../../../../../src/v2/models/base/base.model.js'; +import BaseCollection from '../../../../../src/v2/models/base/base.collection.js'; +import Schema from '../../../../../src/v2/models/base/schema.js'; +import Reference from '../../../../../src/v2/models/base/reference.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const MockModel = class MockEntityModel extends BaseModel {}; +const MockCollection = class MockEntityCollection extends BaseCollection {}; + +describe('Schema', () => { + const rawSchema = { + serviceName: 'service', + schemaVersion: 1, + attributes: { + id: { type: 'string' }, + }, + indexes: { + primary: { pk: { composite: ['id'] } }, + byOrganizationId: { sk: { facets: ['organizationId'] } }, + }, + references: [new Reference('belongs_to', 'Organization')], + }; + + let instance; + + beforeEach(() => { + instance = new Schema(MockModel, MockCollection, rawSchema); + }); + + describe('constructor', () => { + it('constructs a new Schema instance', () => { + const schema = new Schema(MockModel, MockCollection, rawSchema); + + expect(schema.modelClass).to.equal(MockModel); + expect(schema.collectionClass).to.equal(MockCollection); + expect(schema.serviceName).to.equal('service'); + expect(schema.schemaVersion).to.equal(1); + expect(schema.attributes).to.deep.equal({ id: { type: 'string' } }); + expect(schema.indexes).to.deep.equal(rawSchema.indexes); + expect(schema.references).to.deep.equal([{ + options: {}, + target: 'Organization', + type: 'belongs_to', + }]); + }); + + it('throws an error if modelClass does not extend BaseModel', () => { + expect(() => new Schema({}, MockCollection, rawSchema)).to.throw('Model class must extend BaseModel'); + expect(() => new Schema(String, MockCollection, rawSchema)).to.throw('Model class must extend BaseModel'); + }); + + it('throws an error if collectionClass does not extend BaseCollection', () => { + expect(() => new Schema(MockModel, {}, rawSchema)).to.throw('Collection class must extend BaseCollection'); + expect(() => new Schema(MockModel, String, rawSchema)).to.throw('Collection class must extend BaseCollection'); + }); + + it('throws an error if schema does not have a service name', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, serviceName: '' })).to.throw('Schema must have a service name'); + }); + + it('throws an error if schema does not have a positive integer', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, schemaVersion: 0 })).to.throw('Schema version must be a positive integer'); + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, schemaVersion: 'test' })).to.throw('Schema version must be a positive integer'); + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, schemaVersion: undefined })).to.throw('Schema version must be a positive integer'); + }); + + it('throws an error if schema does not have attributes', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, attributes: {} })).to.throw('Schema must have attributes'); + }); + + it('throws an error if schema does not have indexes', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, indexes: {} })).to.throw('Schema must have indexes'); + }); + + it('throws an error if schema does not have references', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, references: 'test' })).to.throw('References must be an array'); + }); + + it('references default to an empty array', () => { + const schema = new Schema(MockModel, MockCollection, { ...rawSchema, references: undefined }); + + expect(schema.references).to.deep.equal([]); + }); + }); + + describe('accessors', () => { + it('getAttribute', () => { + expect(instance.getAttribute('id')).to.deep.equal({ type: 'string' }); + }); + + it('getAttributes', () => { + expect(instance.getAttributes()).to.deep.equal({ id: { type: 'string' } }); + }); + + it('getCollectionName', () => { + expect(instance.getCollectionName()).to.equal('MockEntityCollection'); + }); + + it('getEntityName', () => { + expect(instance.getEntityName()).to.equal('mockEntityModel'); + }); + + it('getIdName', () => { + expect(instance.getIdName()).to.equal('mockEntityModelId'); + }); + + it('getIndexAccessors', () => { + expect(instance.getIndexAccessors()).to.deep.equal([{ + indexName: 'byOrganizationId', + keySets: [['organizationId']], + }]); + }); + + it('getIndexByName', () => { + expect(instance.getIndexByName('primary')).to.deep.equal({ pk: { composite: ['id'] } }); + }); + + it('getIndexes', () => { + expect(instance.getIndexes()).to.deep.equal(rawSchema.indexes); + }); + + it('getIndexes with exclusion', () => { + expect(instance.getIndexes(['primary'])).to.deep.equal({ + byOrganizationId: { sk: { facets: ['organizationId'] } }, + }); + }); + + it('getIndexKeys', () => { + expect(instance.getIndexKeys('byOrganizationId')).to.deep.equal(['organizationId']); + }); + + it('getIndexKeys with non-existent index', () => { + expect(instance.getIndexKeys('non-existent')).to.deep.equal([]); + }); + + it('getModelClass', () => { + expect(instance.getModelClass()).to.equal(MockModel); + }); + + it('getModelName', () => { + expect(instance.getModelName()).to.equal('MockEntityModel'); + }); + + it('getReciprocalReference', () => { + const reciprocalReference = new Reference('belongs_to', 'MockEntityModel'); + const registry = { + getCollection: () => ({ + schema: { getReferenceByTypeAndTarget: () => reciprocalReference }, + }), + }; + + expect(instance.getReciprocalReference(registry, new Reference('has_many', 'Organization'))) + .to.deep.equal(reciprocalReference); + expect(instance.getReciprocalReference(registry, new Reference('belongs_to', 'Organization'))) + .to.be.null; + }); + + it('getReferences', () => { + expect(instance.getReferences()).to.deep.equal([{ + options: {}, + target: 'Organization', + type: 'belongs_to', + }]); + }); + + it('getReferencesByType', () => { + expect(instance.getReferencesByType('belongs_to')).to.deep.equal([{ + options: {}, + target: 'Organization', + type: 'belongs_to', + }]); + }); + + it('getServiceName', () => { + expect(instance.getServiceName()).to.equal('service'); + }); + + it('getVersion', () => { + expect(instance.getVersion()).to.equal(1); + }); + }); + + describe('toElectroDBSchema', () => { + it('returns an ElectroDB-compatible schema', () => { + expect(instance.toElectroDBSchema()).to.deep.equal({ + model: { + entity: 'MockEntityModel', + version: '1', + service: 'service', + }, + attributes: { id: { type: 'string' } }, + indexes: rawSchema.indexes, + }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.collection.test.js new file mode 100755 index 00000000..433fa74d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.collection.test.js @@ -0,0 +1,107 @@ +/* + * 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 Configuration from '../../../../../src/v2/models/configuration/configuration.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ConfigurationCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + configurationId: '2e6d24e8-3a1f-4c2c-9f80-696a177ff699', + queues: { + someQueue: {}, + }, + jobs: [], + version: 1, + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Configuration, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ConfigurationCollection 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('create', () => { + it('creates a new configuration as first version', async () => { + instance.findLatest = stub().resolves(null); + + const result = await instance.create(mockRecord); + + expect(result).to.be.an('object'); + expect(result.getId()).to.equal(mockRecord.configurationId); + }); + + it('creates a new configuration as a new version', async () => { + const latestConfiguration = { + getId: () => 's12345', + getVersion: () => 1, + }; + + instance.findLatest = stub().resolves(latestConfiguration); + mockRecord.version = 2; + + const result = await instance.create(mockRecord); + + expect(result).to.be.an('object'); + expect(result.getId()).to.equal(mockRecord.configurationId); + expect(result.getVersion()).to.equal(2); + }); + }); + + describe('findLatest', () => { + it('returns the latest configuration', async () => { + const mockResult = { configurationId: 's12345' }; + + instance.findByAll = stub().resolves(mockResult); + + const result = await instance.findLatest(); + + expect(result).to.deep.equal(mockResult); + expect(instance.findByAll).to.have.been.calledWithExactly({}, { order: 'desc' }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.model.test.js new file mode 100755 index 00000000..eaaf530c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.model.test.js @@ -0,0 +1,211 @@ +/* + * 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 Configuration from '../../../../../src/v2/models/configuration/configuration.model.js'; +import configurationFixtures from '../../../../fixtures/configurations.fixture.js'; +import { createElectroMocks } from '../../util.js'; +import { sanitizeIdAndAuditFields } from '../../../../../src/v2/util/util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const sampleConfiguration = configurationFixtures[0]; +const site = { + getId: () => 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + getOrganizationId: () => '757ceb98-05c8-4e07-bb23-bc722115b2b0', +}; + +const org = { + getId: () => site.getOrganizationId(), +}; + +describe('ConfigurationModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { ...sampleConfiguration }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Configuration, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Configuration instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('configurationId', () => { + it('gets configurationId', () => { + expect(instance.getId()).to.equal(sampleConfiguration.configurationId); + }); + }); + + describe('attributes', () => { + it('gets version', () => { + expect(instance.getVersion()).to.equal(2); + }); + + it('gets queues', () => { + expect(instance.getQueues()).to.deep.equal(sampleConfiguration.queues); + }); + + it('gets jobs', () => { + expect(instance.getJobs()).to.deep.equal(sampleConfiguration.jobs); + }); + + it('gets handlers', () => { + expect(instance.getHandlers()).to.deep.equal(sampleConfiguration.handlers); + }); + + it('gets handler', () => { + expect(instance.getHandler('apex')).to.deep.equal(sampleConfiguration.handlers.apex); + }); + + it('gets slackRoles', () => { + expect(instance.getSlackRoles()).to.deep.equal(sampleConfiguration.slackRoles); + }); + + it('gets slackRoleMembersByRole', () => { + expect(instance.getSlackRoleMembersByRole('scrape')).to.deep.equal(sampleConfiguration.slackRoles.scrape); + delete instance.record.slackRoles; + expect(instance.getSlackRoleMembersByRole('scrape')).to.deep.equal([]); + }); + }); + + describe('handler enabled/disabled', () => { + it('returns false if a handler does not exist', () => { + expect(instance.isHandlerEnabledForSite('non-existent-handler', site)).to.be.false; + expect(instance.isHandlerEnabledForOrg('non-existent-handler', org)).to.be.false; + }); + + it('returns true if a handler is enabled by default', () => { + expect(instance.isHandlerEnabledForSite('404', site)).to.be.true; + expect(instance.isHandlerEnabledForOrg('404', org)).to.be.true; + }); + + it('returns false if a handler is not enabled by default', () => { + expect(instance.isHandlerEnabledForSite('organic-keywords', site)).to.be.false; + expect(instance.isHandlerEnabledForOrg('organic-keywords', org)).to.be.false; + }); + + it('returns true when a handler is enabled for a site', () => { + expect(instance.isHandlerEnabledForSite('lhs-mobile', site)).to.be.true; + }); + + it('returns false when a handler is disabled for a site', () => { + expect(instance.isHandlerEnabledForSite('cwv', site)).to.be.false; + }); + + it('returns true when a handler is enabled for an organization', () => { + expect(instance.isHandlerEnabledForOrg('lhs-mobile', org)).to.be.true; + }); + + it('returns false when a handler is disabled for an organization', () => { + expect(instance.isHandlerEnabledForOrg('cwv', org)).to.be.false; + }); + + it('gets enabled site ids for a handler', () => { + expect(instance.getEnabledSiteIdsForHandler('lhs-mobile')).to.deep.equal(['c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe']); + delete instance.record.handlers; + expect(instance.getEnabledSiteIdsForHandler('lhs-mobile')).to.deep.equal([]); + }); + }); + + describe('manage handlers', () => { + it('adds a new handler', () => { + const handlerData = { + enabledByDefault: true, + }; + + instance.addHandler('new-handler', handlerData); + expect(instance.getHandler('new-handler')).to.deep.equal(handlerData); + }); + + it('updates handler orgs for a handler disabled by default with enabled', () => { + instance.updateHandlerOrgs('lhs-mobile', org.getId(), true); + expect(instance.getHandler('lhs-mobile').enabled.orgs).to.include(org.getId()); + }); + + it('updates handler orgs for a handler disabled by default with disabled', () => { + instance.updateHandlerOrgs('404', org.getId(), false); + expect(instance.getHandler('404').disabled.orgs).to.include(org.getId()); + }); + + it('updates handler orgs for a handler enabled by default', () => { + instance.updateHandlerOrgs('404', org.getId(), true); + expect(instance.getHandler('404').disabled.orgs).to.not.include(org.getId()); + }); + + it('updates handler sites for a handler disabled by default', () => { + instance.updateHandlerSites('lhs-mobile', site.getId(), true); + expect(instance.getHandler('lhs-mobile').enabled.sites).to.include(site.getId()); + }); + + it('updates handler sites for a handler enabled by default', () => { + instance.updateHandlerSites('404', site.getId(), true); + expect(instance.getHandler('404').disabled.sites).to.not.include(site.getId()); + }); + + it('enables a handler for a site', () => { + instance.enableHandlerForSite('organic-keywords', site); + expect(instance.isHandlerEnabledForSite('organic-keywords', site)).to.be.true; + expect(instance.getHandler('organic-keywords').enabled.sites).to.include(site.getId()); + }); + + it('disables a handler for a site', () => { + instance.enableHandlerForSite('organic-keywords', site); + instance.disableHandlerForSite('organic-keywords', site); + expect(instance.getHandler('organic-keywords').disabled.sites).to.not.include(site.getId()); + }); + + it('enables a handler for an organization', () => { + instance.enableHandlerForOrg('404', org); + expect(instance.getHandler('404').disabled.orgs).to.not.include(org.getId()); + }); + + it('disables a handler for an organization', () => { + instance.enableHandlerForOrg('organic-keywords', org); + instance.disableHandlerForOrg('organic-keywords', org); + expect(instance.getHandler('organic-keywords').enabled.orgs).to.not.include(org.getId()); + }); + }); + + describe('save', () => { + it('saves the configuration', async () => { + instance.collection = { + create: stub().resolves(), + }; + + await instance.save(); + + expect(instance.collection.create).to.have.been.calledOnceWithExactly( + sanitizeIdAndAuditFields('Configuration', instance.toJSON()), + ); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.collection.test.js new file mode 100755 index 00000000..6f3c55e0 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.collection.test.js @@ -0,0 +1,61 @@ +/* + * 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 Experiment from '../../../../../src/v2/models/experiment/experiment.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ExperimentCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + experimentId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Experiment, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ExperimentCollection 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'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.model.test.js new file mode 100755 index 00000000..c8407e3a --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.model.test.js @@ -0,0 +1,203 @@ +/* + * 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 Experiment from '../../../../../src/v2/models/experiment/experiment.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ExperimentModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + experimentId: 'e12345', + siteId: 'site67890', + conversionEventName: 'someConversionEventName', + conversionEventValue: '100', + endDate: '2024-01-01T00:00:00.000Z', + expId: 'someExpId', + name: 'someName', + startDate: '2024-01-01T00:00:00.000Z', + status: 'ACTIVE', + type: 'someType', + url: 'someUrl', + updatedBy: 'someUpdatedBy', + variants: [{ someVariant: 'someVariant' }], + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Experiment, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Experiment instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('experimentId', () => { + it('gets experimentId', () => { + expect(instance.getId()).to.equal('e12345'); + }); + }); + + describe('siteId', () => { + it('gets siteId', () => { + expect(instance.getSiteId()).to.equal('site67890'); + }); + + it('sets siteId', () => { + instance.setSiteId('2c1f0868-cc2d-4358-ba26-a7b5965ee403'); + expect(instance.getSiteId()).to.equal('2c1f0868-cc2d-4358-ba26-a7b5965ee403'); + }); + }); + + describe('conversionEventName', () => { + it('gets conversionEventName', () => { + expect(instance.getConversionEventName()).to.equal('someConversionEventName'); + }); + + it('sets conversionEventName', () => { + instance.setConversionEventName('newConversionEventName'); + expect(instance.getConversionEventName()).to.equal('newConversionEventName'); + }); + }); + + describe('conversionEventValue', () => { + it('gets conversionEventValue', () => { + expect(instance.getConversionEventValue()).to.equal('100'); + }); + + it('sets conversionEventValue', () => { + instance.setConversionEventValue('200'); + expect(instance.getConversionEventValue()).to.equal('200'); + }); + }); + + describe('endDate', () => { + it('gets endDate', () => { + expect(instance.getEndDate()).to.equal('2024-01-01T00:00:00.000Z'); + }); + + it('sets endDate', () => { + const newEndDate = '2024-01-02T00:00:00.000Z'; + instance.setEndDate(newEndDate); + expect(instance.getEndDate()).to.equal(newEndDate); + }); + }); + + describe('expId', () => { + it('gets expId', () => { + expect(instance.getExpId()).to.equal('someExpId'); + }); + + it('sets expId', () => { + instance.setExpId('newExpId'); + expect(instance.getExpId()).to.equal('newExpId'); + }); + }); + + describe('name', () => { + it('gets name', () => { + expect(instance.getName()).to.equal('someName'); + }); + + it('sets name', () => { + instance.setName('newName'); + expect(instance.getName()).to.equal('newName'); + }); + }); + + describe('startDate', () => { + it('gets startDate', () => { + expect(instance.getStartDate()).to.equal('2024-01-01T00:00:00.000Z'); + }); + + it('sets startDate', () => { + const newStartDate = '2024-01-02T00:00:00.000Z'; + instance.setStartDate(newStartDate); + expect(instance.getStartDate()).to.equal(newStartDate); + }); + }); + + describe('status', () => { + it('gets status', () => { + expect(instance.getStatus()).to.equal('ACTIVE'); + }); + + it('sets status', () => { + instance.setStatus('INACTIVE'); + expect(instance.getStatus()).to.equal('INACTIVE'); + }); + }); + + describe('type', () => { + it('gets type', () => { + expect(instance.getType()).to.equal('someType'); + }); + + it('sets type', () => { + instance.setType('newType'); + expect(instance.getType()).to.equal('newType'); + }); + }); + + describe('url', () => { + it('gets url', () => { + expect(instance.getUrl()).to.equal('someUrl'); + }); + + it('sets url', () => { + instance.setUrl('newUrl'); + expect(instance.getUrl()).to.equal('newUrl'); + }); + }); + + describe('updatedBy', () => { + it('gets updatedBy', () => { + expect(instance.getUpdatedBy()).to.equal('someUpdatedBy'); + }); + + it('sets updatedBy', () => { + instance.setUpdatedBy('newUpdatedBy'); + expect(instance.getUpdatedBy()).to.equal('newUpdatedBy'); + }); + }); + + describe('variants', () => { + it('gets variants', () => { + expect(instance.getVariants()).to.deep.equal([{ someVariant: 'someVariant' }]); + }); + + it('sets variants', () => { + instance.setVariants([{ newVariant: 'newVariant' }]); + expect(instance.getVariants()).to.deep.equal([{ newVariant: 'newVariant' }]); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.collection.test.js new file mode 100755 index 00000000..64d6d53d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.collection.test.js @@ -0,0 +1,94 @@ +/* + * 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 ImportJob from '../../../../../src/v2/models/import-job/import-job.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ImportJobCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + importJobId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(ImportJob, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ImportJobCollection 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('allByDateRange', () => { + it('throws an error if the startDate is not a valid iso date', async () => { + await expect(instance.allByDateRange()).to.be.rejectedWith('Invalid start date: undefined'); + }); + + it('throws an error if the endDate is not a valid iso date', async () => { + const startIsoDate = '2024-12-06T08:35:24.125Z'; + await expect(instance.allByDateRange(startIsoDate)).to.be.rejectedWith('Invalid end date: undefined'); + }); + + it('returns all import jobs by date range', async () => { + const startIsoDate = '2024-12-06T08:35:24.125Z'; + const endIsoDate = '2024-12-07T08:35:24.125Z'; + + const mockResult = [{ importJobId: 's12345' }]; + + instance.all = stub().resolves(mockResult); + + const result = await instance.allByDateRange(startIsoDate, endIsoDate); + + expect(result).to.deep.equal(mockResult); + expect(instance.all).to.have.been.calledWithExactly({}, { + between: + { + attribute: 'startedAt', + start: '2024-12-06T08:35:24.125Z', + end: '2024-12-07T08:35:24.125Z', + }, + }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.model.test.js new file mode 100755 index 00000000..03490960 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.model.test.js @@ -0,0 +1,256 @@ +/* + * 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 ImportJob from '../../../../../src/v2/models/import-job/import-job.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ImportJobModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + importJobId: 'sug12345', + baseURL: 'https://example.com', + duration: 0, + endedAt: '2022-01-01T00:00:00.000Z', + failedCount: 0, + hasCustomHeaders: false, + hasCustomImportJs: false, + hashedApiKey: 'someHashedApiKey', + importQueueId: 'iq12345', + initiatedBy: { + apiKeyName: 'someApiKeyName', + imsOrgId: 'someImsOrgId', + imsUserId: 'someImsUserId', + userAgent: 'someUserAgent', + }, + options: { + someOption: 'someValue', + }, + redirectCount: 0, + status: 'RUNNING', + startedAt: '2022-01-01T00:00:00.000Z', + successCount: 0, + urlCount: 0, + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(ImportJob, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the ImportJob instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('importJobId', () => { + it('gets importJobId', () => { + expect(instance.getId()).to.equal('sug12345'); + }); + }); + + describe('baseURL', () => { + it('gets baseURL', () => { + expect(instance.getBaseURL()).to.equal('https://example.com'); + }); + + it('sets baseURL', () => { + const newBaseURL = 'https://newexample.com'; + instance.setBaseURL(newBaseURL); + expect(instance.getBaseURL()).to.equal(newBaseURL); + }); + }); + + describe('duration', () => { + it('gets duration', () => { + expect(instance.getDuration()).to.equal(0); + }); + + it('sets duration', () => { + const newDuration = 100; + instance.setDuration(newDuration); + expect(instance.getDuration()).to.equal(newDuration); + }); + }); + + describe('endedAt', () => { + it('gets endedAt', () => { + expect(instance.getEndedAt()).to.equal('2022-01-01T00:00:00.000Z'); + }); + + it('sets endedAt', () => { + const newEndedAt = '2023-01-01T00:00:00.000Z'; + instance.setEndedAt(newEndedAt); + expect(instance.getEndedAt()).to.equal(newEndedAt); + }); + }); + + describe('failedCount', () => { + it('gets failedCount', () => { + expect(instance.getFailedCount()).to.equal(0); + }); + + it('sets failedCount', () => { + const newFailedCount = 1; + instance.setFailedCount(newFailedCount); + expect(instance.getFailedCount()).to.equal(newFailedCount); + }); + }); + + describe('hasCustomHeaders', () => { + it('gets hasCustomHeaders', () => { + expect(instance.getHasCustomHeaders()).to.equal(false); + }); + + it('sets hasCustomHeaders', () => { + instance.setHasCustomHeaders(true); + expect(instance.getHasCustomHeaders()).to.equal(true); + }); + }); + + describe('hasCustomImportJs', () => { + it('gets hasCustomImportJs', () => { + expect(instance.getHasCustomImportJs()).to.equal(false); + }); + + it('sets hasCustomImportJson', () => { + instance.setHasCustomImportJs(true); + expect(instance.getHasCustomImportJs()).to.equal(true); + }); + }); + + describe('hashedApiKey', () => { + it('gets hashedApiKey', () => { + expect(instance.getHashedApiKey()).to.equal('someHashedApiKey'); + }); + + it('sets hashedApiKey', () => { + const newHashedApiKey = 'someNewHashedApiKey'; + instance.setHashedApiKey(newHashedApiKey); + expect(instance.getHashedApiKey()).to.equal(newHashedApiKey); + }); + }); + + describe('importQueueId', () => { + it('gets importQueueId', () => { + expect(instance.getImportQueueId()).to.equal('iq12345'); + }); + + it('sets importQueueId', () => { + const newImportQueueId = 'iq67890'; + instance.setImportQueueId(newImportQueueId); + expect(instance.getImportQueueId()).to.equal(newImportQueueId); + }); + }); + + describe('initiatedBy', () => { + it('gets initiatedBy', () => { + expect(instance.getInitiatedBy()).to.deep.equal(mockRecord.initiatedBy); + }); + + it('sets initiatedBy', () => { + const newInitiatedBy = { + apiKeyName: 'newApiKeyName', + imsOrgId: 'newImsOrgId', + imsUserId: 'newImsUserId', + userAgent: 'newUserAgent', + }; + instance.setInitiatedBy(newInitiatedBy); + expect(instance.getInitiatedBy()).to.deep.equal(newInitiatedBy); + }); + }); + + describe('options', () => { + it('gets options', () => { + expect(instance.getOptions()).to.deep.equal({ someOption: 'someValue' }); + }); + + it('sets options', () => { + const newOptions = { newOption: 'newValue' }; + instance.setOptions(newOptions); + expect(instance.getOptions()).to.deep.equal(newOptions); + }); + }); + + describe('redirectCount', () => { + it('gets redirectCount', () => { + expect(instance.getRedirectCount()).to.equal(0); + }); + + it('sets redirectCount', () => { + const newRedirectCount = 1; + instance.setRedirectCount(newRedirectCount); + expect(instance.getRedirectCount()).to.equal(newRedirectCount); + }); + }); + + describe('status', () => { + it('gets status', () => { + expect(instance.getStatus()).to.equal('RUNNING'); + }); + + it('sets status', () => { + const newStatus = 'COMPLETE'; + instance.setStatus(newStatus); + expect(instance.getStatus()).to.equal(newStatus); + }); + }); + + describe('startedAt', () => { + it('gets startedAt', () => { + expect(instance.getStartedAt()).to.equal('2022-01-01T00:00:00.000Z'); + }); + }); + + describe('successCount', () => { + it('gets successCount', () => { + expect(instance.getSuccessCount()).to.equal(0); + }); + + it('sets successCount', () => { + const newSuccessCount = 1; + instance.setSuccessCount(newSuccessCount); + expect(instance.getSuccessCount()).to.equal(newSuccessCount); + }); + }); + + describe('urlCount', () => { + it('gets urlCount', () => { + expect(instance.getUrlCount()).to.equal(0); + }); + + it('sets urlCount', () => { + const newUrlCount = 1; + instance.setUrlCount(newUrlCount); + expect(instance.getUrlCount()).to.equal(newUrlCount); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.collection.test.js new file mode 100755 index 00000000..9291acb6 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.collection.test.js @@ -0,0 +1,61 @@ +/* + * 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 ImportUrl from '../../../../../src/v2/models/import-url/import-url.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ImportUrlCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + importUrlId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(ImportUrl, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ImportUrlCollection 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'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.model.test.js new file mode 100755 index 00000000..05a0202b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.model.test.js @@ -0,0 +1,141 @@ +/* + * 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 ImportUrl from '../../../../../src/v2/models/import-url/import-url.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ImportUrlModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + importUrlId: 'sug12345', + importJobId: 'ij12345', + expiresAt: '2022-01-01T00:00:00.000Z', + file: 'someFile', + path: 'somePath', + reason: 'someReason', + status: 'PENDING', + url: 'https://example.com', + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(ImportUrl, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the ImportUrl instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('importUrlId', () => { + it('gets importUrlId', () => { + expect(instance.getId()).to.equal('sug12345'); + }); + }); + + describe('importJobId', () => { + it('gets importJobId', () => { + expect(instance.getImportJobId()).to.equal('ij12345'); + }); + + it('sets importJobId', () => { + instance.setImportJobId('699120e9-7adb-4c97-b1c2-403b6ea9e057'); + expect(instance.getImportJobId()).to.equal('699120e9-7adb-4c97-b1c2-403b6ea9e057'); + }); + }); + + describe('expiresAt', () => { + it('gets expiresAt', () => { + expect(instance.getExpiresAt()).to.equal('2022-01-01T00:00:00.000Z'); + }); + + it('sets expiresAt', () => { + instance.setExpiresAt('2024-01-01T00:00:00.000Z'); + expect(instance.getExpiresAt()).to.equal('2024-01-01T00:00:00.000Z'); + }); + }); + + describe('file', () => { + it('gets file', () => { + expect(instance.getFile()).to.equal('someFile'); + }); + + it('sets file', () => { + instance.setFile('newFile'); + expect(instance.getFile()).to.equal('newFile'); + }); + }); + + describe('path', () => { + it('gets path', () => { + expect(instance.getPath()).to.equal('somePath'); + }); + + it('sets path', () => { + instance.setPath('newPath'); + expect(instance.getPath()).to.equal('newPath'); + }); + }); + + describe('reason', () => { + it('gets reason', () => { + expect(instance.getReason()).to.equal('someReason'); + }); + + it('sets reason', () => { + instance.setReason('newReason'); + expect(instance.getReason()).to.equal('newReason'); + }); + }); + + describe('status', () => { + it('gets status', () => { + expect(instance.getStatus()).to.equal('PENDING'); + }); + + it('sets status', () => { + instance.setStatus('COMPLETE'); + expect(instance.getStatus()).to.equal('COMPLETE'); + }); + }); + + describe('url', () => { + it('gets url', () => { + expect(instance.getUrl()).to.equal('https://example.com'); + }); + + it('sets url', () => { + instance.setUrl('https://example.org'); + expect(instance.getUrl()).to.equal('https://example.org'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.collection.test.js new file mode 100755 index 00000000..1f2672a3 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.collection.test.js @@ -0,0 +1,61 @@ +/* + * 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 KeyEvent from '../../../../../src/v2/models/key-event/key-event.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('KeyEventCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + keyEventId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(KeyEvent, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the KeyEventCollection 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'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.model.test.js new file mode 100755 index 00000000..193d4901 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.model.test.js @@ -0,0 +1,106 @@ +/* + * 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 KeyEvent from '../../../../../src/v2/models/key-event/key-event.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('KeyEventModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + keyEventId: 'k12345', + siteId: 's12345', + name: 'someName', + type: 'CONTENT', + time: '2022-01-01T00:00:00.000Z', + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(KeyEvent, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the KeyEvent instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('keyEventId', () => { + it('gets keyEventId', () => { + expect(instance.getId()).to.equal('k12345'); + }); + }); + + describe('siteId', () => { + it('gets siteId', () => { + expect(instance.getSiteId()).to.equal('s12345'); + }); + + it('sets siteId', () => { + instance.setSiteId('51f2eab9-2cd8-47a0-acd0-a2b00d916792'); + expect(instance.getSiteId()).to.equal('51f2eab9-2cd8-47a0-acd0-a2b00d916792'); + }); + }); + + describe('name', () => { + it('gets name', () => { + expect(instance.getName()).to.equal('someName'); + }); + + it('sets name', () => { + instance.setName('newName'); + expect(instance.getName()).to.equal('newName'); + }); + }); + + describe('type', () => { + it('gets type', () => { + expect(instance.getType()).to.equal('CONTENT'); + }); + + it('sets type', () => { + instance.setType('STATUS CHANGE'); + expect(instance.getType()).to.equal('STATUS CHANGE'); + }); + }); + + describe('time', () => { + it('gets time', () => { + expect(instance.getTime()).to.equal('2022-01-01T00:00:00.000Z'); + }); + + it('sets time', () => { + const newTime = '2023-01-01T00:00:00.000Z'; + instance.setTime(newTime); + expect(instance.getTime()).to.equal(newTime); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/model.factory.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/model.factory.test.js deleted file mode 100755 index f3a1c345..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/model.factory.test.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import ModelFactory from '../../../../src/v2/models/model.factory.js'; - -chaiUse(chaiAsPromised); - -/** - * Mock services and logger for unit testing - */ -const mockElectroService = { - entities: {}, -}; - -const mockLogger = { - error: spy(), -}; - -// ModelFactory Unit Tests -describe('ModelFactory', () => { - let modelFactoryInstance; - let mockCollectionInstance; - - beforeEach(() => { - mockCollectionInstance = { - findById: stub(), - create: stub(), - }; - modelFactoryInstance = new ModelFactory(mockElectroService, mockLogger); - }); - - describe('constructor', () => { - it('initializes the ModelFactory instance correctly', () => { - expect(modelFactoryInstance).to.be.an('object'); - expect(modelFactoryInstance.service).to.equal(mockElectroService); - expect(modelFactoryInstance.logger).to.equal(mockLogger); - expect(modelFactoryInstance.models).to.be.a('map'); - }); - }); - - describe('getCollection', () => { - it('returns an existing collection if already initialized', () => { - modelFactoryInstance.models.set('TestCollection', mockCollectionInstance); - const result = modelFactoryInstance.getCollection('TestCollection'); - expect(result).to.equal(mockCollectionInstance); - }); - - it('throws an error if the collection name is not valid', () => { - expect(() => modelFactoryInstance.getCollection('InvalidCollection')) - .to.throw('Collection InvalidCollection not found'); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.collection.test.js deleted file mode 100644 index d7c5fa02..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.collection.test.js +++ /dev/null @@ -1,154 +0,0 @@ -/* - * 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 { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import Opportunity from '../../../../src/v2/models/opportunity.model.js'; -import OpportunityCollection from '../../../../src/v2/models/opportunity.collection.js'; -import OpportunitySchema from '../../../../src/v2/schema/opportunity.schema.js'; - -chaiUse(chaiAsPromised); - -const opportunityEntity = new Entity(OpportunitySchema); - -const mockElectroService = { - entities: { - opportunity: { - model: { - name: 'opportunity', - schema: opportunityEntity.model.schema, - original: { - references: {}, - }, - }, - query: { - bySiteId: stub(), - bySiteIdAndStatus: stub(), - }, - put: stub(), - }, - }, -}; - -// OpportunityCollection Unit Tests -describe('OpportunityCollection', () => { - let opportunityCollectionInstance; - let mockLogger; - let mockModelFactory; - - const mockRecord = { - opportunityId: 'op12345', - siteId: 'site67890', - data: { - foo: 'bar', - bing: 'batz', - }, - }; - const mockOpportunityModel = new Opportunity( - mockElectroService, - mockModelFactory, - mockRecord, - mockLogger, - ); - - beforeEach(() => { - mockLogger = { - error: spy(), - warn: spy(), - }; - - mockModelFactory = { - getCollection: stub(), - }; - - opportunityCollectionInstance = new OpportunityCollection( - mockElectroService, - mockModelFactory, - mockLogger, - ); - }); - - describe('constructor', () => { - it('initializes the OpportunityCollection instance correctly', () => { - expect(opportunityCollectionInstance).to.be.an('object'); - expect(opportunityCollectionInstance.electroService).to.equal(mockElectroService); - expect(opportunityCollectionInstance.modelFactory).to.equal(mockModelFactory); - expect(opportunityCollectionInstance.log).to.equal(mockLogger); - }); - }); - - describe('allBySiteId', () => { - it('returns an array of Opportunity instances when opportunities exist', async () => { - const mockFindResults = { data: [mockRecord] }; - mockElectroService.entities.opportunity.query.bySiteId.returns( - { go: () => Promise.resolve(mockFindResults) }, - ); - - const results = await opportunityCollectionInstance.allBySiteId('site67890'); - expect(results).to.be.an('array').that.has.length(1); - expect(results[0]).to.be.instanceOf(Opportunity); - expect(results[0].record).to.deep.include(mockOpportunityModel.record); - }); - - it('returns an empty array if no opportunities exist for the given site ID', async () => { - mockElectroService.entities.opportunity.query.bySiteId.returns( - { go: () => Promise.resolve([]) }, - ); - - const results = await opportunityCollectionInstance.allBySiteId('site67890'); - expect(results).to.be.an('array').that.is.empty; - }); - - it('throws an error if siteId is not provided', async () => { - await expect(opportunityCollectionInstance.allBySiteId('')) - .to.be.rejectedWith('SiteId is required'); - }); - }); - - describe('allBySiteIdAndStatus', () => { - it('returns an array of Opportunity instances when opportunities exist', async () => { - const mockFindResults = { data: [mockRecord] }; - mockElectroService.entities.opportunity.query.bySiteIdAndStatus.returns( - { go: () => Promise.resolve(mockFindResults) }, - ); - - const results = await opportunityCollectionInstance.allBySiteIdAndStatus('site67890', 'IN_PROGRESS'); - expect(results).to.be.an('array').that.has.length(1); - expect(results[0]).to.be.instanceOf(Opportunity); - expect(results[0].record).to.deep.include(mockOpportunityModel.record); - }); - - it('returns an empty array if no opportunities exist for the given site ID and status', async () => { - mockElectroService.entities.opportunity.query.bySiteIdAndStatus.returns( - { go: () => Promise.resolve([]) }, - ); - - const results = await opportunityCollectionInstance.allBySiteIdAndStatus('site67890', 'IN_PROGRESS'); - expect(results).to.be.an('array').that.is.empty; - }); - - it('throws an error if siteId is not provided', async () => { - await expect(opportunityCollectionInstance.allBySiteIdAndStatus('', 'IN_PROGRESS')) - .to.be.rejectedWith('SiteId is required'); - }); - - it('throws an error if status is not provided', async () => { - await expect(opportunityCollectionInstance.allBySiteIdAndStatus('site67890', '')) - .to.be.rejectedWith('Status is required'); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.model.test.js deleted file mode 100644 index 7870dde5..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.model.test.js +++ /dev/null @@ -1,227 +0,0 @@ -/* - * 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 { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import Opportunity from '../../../../src/v2/models/opportunity.model.js'; -import OpportunitySchema from '../../../../src/v2/schema/opportunity.schema.js'; - -chaiUse(chaiAsPromised); - -const { attributes } = new Entity(OpportunitySchema).model.schema; - -const mockElectroService = { - entities: { - opportunity: { - model: { - name: 'opportunity', - schema: { attributes }, - original: { - references: {}, - }, - indexes: { - primary: { - pk: { - field: 'pk', - composite: ['opportunityId'], - }, - }, - }, - }, - patch: stub().returns({ - set: stub(), - }), - }, - }, -}; - -describe('Opportunity', () => { - let opportunityInstance; - let mockModelFactory; - let mockLogger; - - const mockRecord = { - opportunityId: 'op12345', - siteId: 'site67890', - auditId: 'audit001', - title: 'Test Opportunity', - description: 'This is a test opportunity.', - runbook: 'http://runbook.url', - guidance: 'Follow these steps.', - type: 'SEO', - status: 'NEW', - origin: 'ESS_OPS', - tags: ['tag1', 'tag2'], - data: { - additionalInfo: 'info', - }, - }; - - beforeEach(() => { - mockModelFactory = { - getCollection: stub(), - }; - - mockLogger = { - error: spy(), - }; - - opportunityInstance = new Opportunity( - mockElectroService, - mockModelFactory, - mockRecord, - mockLogger, - ); - }); - - describe('constructor', () => { - it('initializes the Opportunity instance correctly', () => { - expect(opportunityInstance).to.be.an('object'); - expect(opportunityInstance.record).to.deep.equal(mockRecord); - }); - }); - - describe('addSuggestions', () => { - it('adds related suggestions to the opportunity', async () => { - const mockSuggestionCollection = { - createMany: stub().returns(Promise.resolve({ id: 'suggestion-1' })), - }; - mockModelFactory.getCollection.withArgs('SuggestionCollection').returns(mockSuggestionCollection); - - const suggestion = await opportunityInstance.addSuggestions([{ text: 'Suggestion text' }]); - expect(suggestion).to.deep.equal({ id: 'suggestion-1' }); - expect(mockModelFactory.getCollection.calledOnceWith('SuggestionCollection')).to.be.true; - expect(mockSuggestionCollection.createMany.calledOnceWith([{ text: 'Suggestion text', opportunityId: 'op12345' }])).to.be.true; - }); - }); - - describe('getSiteId and setSiteId', () => { - it('returns the site ID of the opportunity', () => { - expect(opportunityInstance.getSiteId()).to.equal('site67890'); - }); - - it('sets the site ID of the opportunity', () => { - opportunityInstance.setSiteId('ef39921f-9a02-41db-b491-02c98987d956'); - expect(opportunityInstance.record.siteId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); - }); - }); - - describe('getAuditId and setAuditId', () => { - it('returns the audit ID of the opportunity', () => { - expect(opportunityInstance.getAuditId()).to.equal('audit001'); - }); - - it('sets the audit ID of the opportunity', () => { - opportunityInstance.setAuditId('ef39921f-9a02-41db-b491-02c98987d956'); - expect(opportunityInstance.record.auditId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); - }); - }); - - describe('getRunbook and setRunbook', () => { - it('returns the runbook reference', () => { - expect(opportunityInstance.getRunbook()).to.equal('http://runbook.url'); - }); - - it('sets the runbook reference', () => { - opportunityInstance.setRunbook('http://new.runbook.url'); - expect(opportunityInstance.record.runbook).to.equal('http://new.runbook.url'); - }); - }); - - describe('getGuidance and setGuidance', () => { - it('returns the guidance information', () => { - expect(opportunityInstance.getGuidance()).to.equal('Follow these steps.'); - }); - - it('sets the guidance information', () => { - opportunityInstance.setGuidance({ text: 'New guidance text' }); - expect(opportunityInstance.record.guidance).to.eql({ text: 'New guidance text' }); - }); - }); - - describe('getTitle and setTitle', () => { - it('returns the title of the opportunity', () => { - expect(opportunityInstance.getTitle()).to.equal('Test Opportunity'); - }); - - it('sets the title of the opportunity', () => { - opportunityInstance.setTitle('New Opportunity Title'); - expect(opportunityInstance.record.title).to.equal('New Opportunity Title'); - }); - }); - - describe('getDescription and setDescription', () => { - it('returns the description of the opportunity', () => { - expect(opportunityInstance.getDescription()).to.equal('This is a test opportunity.'); - }); - - it('sets the description of the opportunity', () => { - opportunityInstance.setDescription('Updated description.'); - expect(opportunityInstance.record.description).to.equal('Updated description.'); - }); - }); - - describe('getType', () => { - it('returns the type of the opportunity', () => { - expect(opportunityInstance.getType()).to.equal('SEO'); - }); - }); - - describe('getStatus and setStatus', () => { - it('returns the status of the opportunity', () => { - expect(opportunityInstance.getStatus()).to.equal('NEW'); - }); - - it('sets the status of the opportunity', () => { - opportunityInstance.setStatus('IN_PROGRESS'); - expect(opportunityInstance.record.status).to.equal('IN_PROGRESS'); - }); - }); - - describe('getOrigin and setOrigin', () => { - it('returns the origin of the opportunity', () => { - expect(opportunityInstance.getOrigin()).to.equal('ESS_OPS'); - }); - - it('sets the origin of the opportunity', () => { - opportunityInstance.setOrigin('AI'); - expect(opportunityInstance.record.origin).to.equal('AI'); - }); - }); - - describe('getTags and setTags', () => { - it('returns the tags of the opportunity', () => { - expect(opportunityInstance.getTags()).to.deep.equal(['tag1', 'tag2']); - }); - - it('sets the tags of the opportunity', () => { - opportunityInstance.setTags(['newTag1', 'newTag2']); - expect(opportunityInstance.record.tags).to.deep.equal(['newTag1', 'newTag2']); - }); - }); - - describe('getData and setData', () => { - it('returns additional data for the opportunity', () => { - expect(opportunityInstance.getData()).to.deep.equal({ additionalInfo: 'info' }); - }); - - it('sets additional data for the opportunity', () => { - opportunityInstance.setData({ newInfo: 'updatedInfo' }); - expect(opportunityInstance.record.data).to.deep.equal({ newInfo: 'updatedInfo' }); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.collection.test.js new file mode 100755 index 00000000..093e0145 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.collection.test.js @@ -0,0 +1,75 @@ +/* + * 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 Opportunity from '../../../../../src/v2/models/opportunity/opportunity.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('OpportunityCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + opportunityId: 'op12345', + siteId: 'site67890', + auditId: 'audit001', + title: 'Test Opportunity', + description: 'This is a test opportunity.', + runbook: 'http://runbook.url', + guidance: 'Follow these steps.', + type: 'SEO', + status: 'NEW', + origin: 'ESS_OPS', + tags: ['tag1', 'tag2'], + data: { + additionalInfo: 'info', + }, + updatedAt: '2022-01-01T00:00:00.000Z', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Opportunity, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the OpportunityCollection 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'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.model.test.js new file mode 100755 index 00000000..cbce5081 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.model.test.js @@ -0,0 +1,196 @@ +/* + * 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 Opportunity from '../../../../../src/v2/models/opportunity/opportunity.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('OpportunityModel', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockRecord; + + beforeEach(() => { + mockRecord = { + opportunityId: 'op12345', + siteId: 'site67890', + auditId: 'audit001', + title: 'Test Opportunity', + description: 'This is a test opportunity.', + runbook: 'http://runbook.url', + guidance: 'Follow these steps.', + type: 'SEO', + status: 'NEW', + origin: 'ESS_OPS', + tags: ['tag1', 'tag2'], + data: { + additionalInfo: 'info', + }, + }; + + ({ + mockElectroService, + mockEntityRegistry, + model: instance, + } = createElectroMocks(Opportunity, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Opportunity instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('addSuggestions', () => { + it('adds related suggestions to the opportunity', async () => { + const mockSuggestionCollection = { + createMany: stub().returns(Promise.resolve({ id: 'suggestion-1' })), + }; + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(mockSuggestionCollection); + + const suggestion = await instance.addSuggestions([{ text: 'Suggestion text' }]); + expect(suggestion).to.deep.equal({ id: 'suggestion-1' }); + expect(mockEntityRegistry.getCollection.calledWith('SuggestionCollection')).to.be.true; + expect(mockSuggestionCollection.createMany.calledOnceWith([{ text: 'Suggestion text', opportunityId: 'op12345' }])).to.be.true; + }); + }); + + describe('getSiteId and setSiteId', () => { + it('returns the site ID of the opportunity', () => { + expect(instance.getSiteId()).to.equal('site67890'); + }); + + it('sets the site ID of the opportunity', () => { + instance.setSiteId('ef39921f-9a02-41db-b491-02c98987d956'); + expect(instance.record.siteId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); + }); + }); + + describe('getAuditId and setAuditId', () => { + it('returns the audit ID of the opportunity', () => { + expect(instance.getAuditId()).to.equal('audit001'); + }); + + it('sets the audit ID of the opportunity', () => { + instance.setAuditId('ef39921f-9a02-41db-b491-02c98987d956'); + expect(instance.record.auditId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); + }); + }); + + describe('getRunbook and setRunbook', () => { + it('returns the runbook reference', () => { + expect(instance.getRunbook()).to.equal('http://runbook.url'); + }); + + it('sets the runbook reference', () => { + instance.setRunbook('http://new.runbook.url'); + expect(instance.record.runbook).to.equal('http://new.runbook.url'); + }); + }); + + describe('getGuidance and setGuidance', () => { + it('returns the guidance information', () => { + expect(instance.getGuidance()).to.equal('Follow these steps.'); + }); + + it('sets the guidance information', () => { + instance.setGuidance({ text: 'New guidance text' }); + expect(instance.record.guidance).to.eql({ text: 'New guidance text' }); + }); + }); + + describe('getTitle and setTitle', () => { + it('returns the title of the opportunity', () => { + expect(instance.getTitle()).to.equal('Test Opportunity'); + }); + + it('sets the title of the opportunity', () => { + instance.setTitle('New Opportunity Title'); + expect(instance.record.title).to.equal('New Opportunity Title'); + }); + }); + + describe('getDescription and setDescription', () => { + it('returns the description of the opportunity', () => { + expect(instance.getDescription()).to.equal('This is a test opportunity.'); + }); + + it('sets the description of the opportunity', () => { + instance.setDescription('Updated description.'); + expect(instance.record.description).to.equal('Updated description.'); + }); + }); + + describe('getType', () => { + it('returns the type of the opportunity', () => { + expect(instance.getType()).to.equal('SEO'); + }); + }); + + describe('getStatus and setStatus', () => { + it('returns the status of the opportunity', () => { + expect(instance.getStatus()).to.equal('NEW'); + }); + + it('sets the status of the opportunity', () => { + instance.setStatus('IN_PROGRESS'); + expect(instance.record.status).to.equal('IN_PROGRESS'); + }); + }); + + describe('getOrigin and setOrigin', () => { + it('returns the origin of the opportunity', () => { + expect(instance.getOrigin()).to.equal('ESS_OPS'); + }); + + it('sets the origin of the opportunity', () => { + instance.setOrigin('AI'); + expect(instance.record.origin).to.equal('AI'); + }); + }); + + describe('getTags and setTags', () => { + it('returns the tags of the opportunity', () => { + expect(instance.getTags()).to.deep.equal(['tag1', 'tag2']); + }); + + it('sets the tags of the opportunity', () => { + instance.setTags(['newTag1', 'newTag2']); + expect(instance.record.tags).to.deep.equal(['newTag1', 'newTag2']); + }); + }); + + describe('getData and setData', () => { + it('returns additional data for the opportunity', () => { + expect(instance.getData()).to.deep.equal({ additionalInfo: 'info' }); + }); + + it('sets additional data for the opportunity', () => { + instance.setData({ newInfo: 'updatedInfo' }); + expect(instance.record.data).to.deep.equal({ newInfo: 'updatedInfo' }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.collection.test.js new file mode 100755 index 00000000..df823d72 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.collection.test.js @@ -0,0 +1,61 @@ +/* + * 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 Organization from '../../../../../src/v2/models/organization/organization.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('OrganizationCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + organizationId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Organization, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the OrganizationCollection 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'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.model.test.js new file mode 100755 index 00000000..261a1f70 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.model.test.js @@ -0,0 +1,100 @@ +/* + * 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 Organization from '../../../../../src/v2/models/organization/organization.model.js'; +import organizationFixtures from '../../../../fixtures/organizations.fixture.js'; +import { Config } from '../../../../../src/models/site/config.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const sampleOrganization = organizationFixtures[0]; + +describe('OrganizationModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = sampleOrganization; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Organization, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Organization instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('organizationId', () => { + it('gets organizationId', () => { + expect(instance.getId()).to.equal('4854e75e-894b-4a74-92bf-d674abad1423'); + }); + }); + + describe('config', () => { + it('gets config', () => { + const config = Config.toDynamoItem(instance.getConfig()); + delete config.imports; + expect(config).to.deep.equal(sampleOrganization.config); + }); + }); + + describe('name', () => { + it('gets name', () => { + expect(instance.getName()).to.equal('0-1234Name'); + }); + + it('sets name', () => { + instance.setName('Some Name'); + expect(instance.record.name).to.equal('Some Name'); + }); + }); + + describe('imsOrgId', () => { + it('gets imsOrgId', () => { + expect(instance.getImsOrgId()).to.equal('0-1234@AdobeOrg'); + }); + + it('sets imsOrgId', () => { + instance.setImsOrgId('newImsOrgId'); + expect(instance.getImsOrgId()).to.equal('newImsOrgId'); + }); + }); + + describe('fulfillableItems', () => { + it('gets fulfillableItems', () => { + expect(instance.getFulfillableItems()).to.deep.equal(undefined); + }); + + it('sets fulfillableItems', () => { + instance.setFulfillableItems(['item3', 'item4']); + expect(instance.getFulfillableItems()).to.deep.equal(['item3', 'item4']); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/site-candidate/site-candidate.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/site-candidate/site-candidate.collection.test.js new file mode 100755 index 00000000..ed74a345 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/site-candidate/site-candidate.collection.test.js @@ -0,0 +1,61 @@ +/* + * 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 SiteCandidate from '../../../../../src/v2/models/site-candidate/site-candidate.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SiteCandidateCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + siteCandidateId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(SiteCandidate, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the SiteCandidateCollection 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'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/site-top-page/site-top-page.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/site-top-page/site-top-page.collection.test.js new file mode 100755 index 00000000..bd4defe8 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/site-top-page/site-top-page.collection.test.js @@ -0,0 +1,93 @@ +/* + * 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 SiteTopPage from '../../../../../src/v2/models/site-top-page/site-top-page.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SiteTopPageCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + siteTopPageId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(SiteTopPage, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the SiteTopPageCollection 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('removeForSiteId', () => { + it('throws an error if siteId is not provided', async () => { + await expect(instance.removeForSiteId()).to.be.rejectedWith('SiteId is required'); + }); + + it('removes all SiteTopPages for a given siteId', async () => { + const siteId = 'site12345'; + + instance.allBySiteId = stub().resolves([model]); + + await instance.removeForSiteId(siteId); + + expect(instance.allBySiteId.calledOnceWith(siteId)).to.be.true; + expect(mockElectroService.entities.siteTopPage.delete.calledOnceWith([{ siteTopPageId: 's12345' }])) + .to.be.true; + }); + + it('remove all SiteTopPages for a given siteId, source and geo', async () => { + const siteId = 'site12345'; + const source = 'ahrefs'; + const geo = 'global'; + + instance.allBySiteIdAndSourceAndGeo = stub().resolves([model]); + + await instance.removeForSiteId(siteId, source, geo); + + expect(instance.allBySiteIdAndSourceAndGeo).to.have.been.calledOnceWith(siteId, source, geo); + expect(mockElectroService.entities.siteTopPage.delete).to.have.been.calledOnceWith([{ siteTopPageId: 's12345' }]); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.collection.test.js new file mode 100755 index 00000000..856649d9 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.collection.test.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. + */ + +/* 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 Site from '../../../../../src/v2/models/site/site.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SiteCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + siteId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Site, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the SiteCollection 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('allSitesToAudit', () => { + it('returns all sites to audit', async () => { + instance.all = stub().resolves([{ getId: () => 's12345' }]); + + const result = await instance.allSitesToAudit(); + + expect(result).to.deep.equal(['s12345']); + expect(instance.all).to.have.been.calledOnceWithExactly({ attributes: ['siteId'] }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.model.test.js new file mode 100755 index 00000000..5786d85d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.model.test.js @@ -0,0 +1,178 @@ +/* + * 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 Site from '../../../../../src/v2/models/site/site.model.js'; +import siteFixtures from '../../../../fixtures/sites.fixture.js'; +import { Config } from '../../../../../src/models/site/config.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const sampleSite = siteFixtures[0]; + +describe('SiteModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = sampleSite; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Site, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Site instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('siteId', () => { + it('gets siteId', () => { + expect(instance.getId()).to.equal('5d6d4439-6659-46c2-b646-92d110fa5a52'); + }); + }); + + describe('organizationId', () => { + it('gets organizationId', () => { + expect(instance.getOrganizationId()).to.equal('4854e75e-894b-4a74-92bf-d674abad1423'); + }); + + it('sets organizationId', () => { + instance.setOrganizationId('1e9c6f94-f226-41f3-9005-4bb766765ac2'); + expect(instance.record.organizationId).to.equal('1e9c6f94-f226-41f3-9005-4bb766765ac2'); + }); + }); + + describe('baseURL', () => { + it('gets baseURL', () => { + expect(instance.getBaseURL()).to.equal('https://example0.com'); + }); + + it('sets baseURL', () => { + instance.setBaseURL('https://www.example.org'); + expect(instance.getBaseURL()).to.equal('https://www.example.org'); + }); + }); + + describe('config', () => { + it('gets config', () => { + const config = Config.toDynamoItem(instance.getConfig()); + delete config.imports; + expect(config).to.deep.equal(sampleSite.config); + }); + }); + + describe('gitHubURL', () => { + it('gets gitHubURL', () => { + expect(instance.getGitHubURL()).to.equal('https://github.com/org-0/test-repo'); + }); + + it('sets gitHubURL', () => { + instance.setGitHubURL('new-github-url'); + expect(instance.getGitHubURL()).to.equal('new-github-url'); + }); + }); + + describe('deliveryType', () => { + it('gets deliveryType', () => { + expect(instance.getDeliveryType()).to.equal('aem_edge'); + }); + + it('sets deliveryType', () => { + instance.setDeliveryType('aem_cs'); + expect(instance.getDeliveryType()).to.equal('aem_cs'); + }); + }); + + describe('hlxConfig', () => { + it('gets hlxConfig', () => { + expect(instance.getHlxConfig()).to.deep.equal(undefined); + }); + + it('sets hlxConfig', () => { + const newHlxConfig = { bar: 'baz' }; + instance.setHlxConfig(newHlxConfig); + expect(instance.getHlxConfig()).to.deep.equal(newHlxConfig); + }); + }); + + describe('isLive', () => { + it('gets isLive', () => { + expect(instance.getIsLive()).to.equal(true); + }); + + it('sets isLive', () => { + instance.setIsLive(false); + expect(instance.getIsLive()).to.equal(false); + }); + }); + + describe('isLiveToggledAt', () => { + it('gets isLiveToggledAt', () => { + expect(instance.getIsLiveToggledAt()).to.equal('2024-11-29T07:45:55.952Z'); + }); + + it('sets isLiveToggledAt', () => { + instance.setIsLiveToggledAt('2024-01-02T00:00:00.000Z'); + expect(instance.getIsLiveToggledAt()).to.equal('2024-01-02T00:00:00.000Z'); + }); + }); + + describe('getLatestAuditByType', () => { + it('returns the latest audit by type', async () => { + const mockAudit = { + auditType: 'someAuditType', + auditedAt: '2024-01-01T00:00:00.000Z', + }; + + const mockFind = stub().returns(mockAudit); + + instance.entityRegistry = { + getCollection: stub().returns({ findByIndexKeys: mockFind }), + }; + + const latestAudit = await instance.getLatestAuditByType('someAuditType'); + + expect(latestAudit).to.deep.equal(mockAudit); + expect(instance.entityRegistry.getCollection).to.have.been.calledOnceWithExactly('AuditCollection'); + expect(mockFind).to.have.been.calledOnceWithExactly( + { siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', auditType: 'someAuditType' }, + ); + }); + }); + + describe('toggleLive', () => { + it('toggles the site live status', async () => { + expect(instance.getIsLive()).to.equal(false); + + instance.toggleLive(); + + expect(instance.getIsLive()).to.equal(true); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js deleted file mode 100644 index 96d50670..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js +++ /dev/null @@ -1,197 +0,0 @@ -/* - * 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 { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import SuggestionCollection from '../../../../src/v2/models/suggestion.collection.js'; -import Suggestion from '../../../../src/v2/models/suggestion.model.js'; -import SuggestionSchema from '../../../../src/v2/schema/suggestion.schema.js'; - -chaiUse(chaiAsPromised); - -const { attributes } = new Entity(SuggestionSchema).model.schema; - -const mockElectroService = { - entities: { - suggestion: { - model: { - name: 'suggestion', - schema: { attributes }, - original: { - references: {}, - }, - indexes: { - primary: { - pk: { - field: 'pk', - composite: ['suggestionId'], - }, - }, - }, - }, - query: { - byOpportunityId: stub(), - byOpportunityIdAndStatus: stub(), - }, - put: stub().returns({ - go: stub().resolves({}), - }), - patch: stub().returns({ - set: stub(), - }), - }, - }, -}; - -// SuggestionCollection Unit Tests -describe('SuggestionCollection', () => { - let suggestionCollectionInstance; - let mockLogger; - let mockModelFactory; - - const mockRecord = { - suggestionId: 's12345', - opportunityId: 'op67890', - data: { - title: 'Test Suggestion', - description: 'This is a test suggestion.', - }, - }; - const mockSuggestionModel = new Suggestion( - mockElectroService, - mockModelFactory, - mockRecord, - mockLogger, - ); - - beforeEach(() => { - mockLogger = { - error: spy(), - warn: spy(), - }; - - mockModelFactory = { - getCollection: stub(), - }; - - suggestionCollectionInstance = new SuggestionCollection( - mockElectroService, - mockModelFactory, - mockLogger, - ); - }); - - describe('constructor', () => { - it('initializes the SuggestionCollection instance correctly', () => { - expect(suggestionCollectionInstance).to.be.an('object'); - expect(suggestionCollectionInstance.electroService).to.equal(mockElectroService); - expect(suggestionCollectionInstance.modelFactory).to.equal(mockModelFactory); - expect(suggestionCollectionInstance.log).to.equal(mockLogger); - }); - }); - - describe('allByOpportunityId', () => { - it('returns the suggestions by opportunity', async () => { - const mockFindResults = { data: [mockRecord] }; - mockElectroService.entities.suggestion.query.byOpportunityId.returns( - { go: () => Promise.resolve(mockFindResults) }, - ); - - const results = await suggestionCollectionInstance.allByOpportunityId('op67890'); - expect(results).to.be.an('array').that.has.length(1); - expect(results[0]).to.be.instanceOf(Suggestion); - expect(results[0].record).to.deep.include(mockSuggestionModel.record); - }); - - it('returns an empty array if no suggestions exist for the given opportunity ID', async () => { - mockElectroService.entities.suggestion.query.byOpportunityId.returns( - { go: () => Promise.resolve([]) }, - ); - - const results = await suggestionCollectionInstance.allByOpportunityId('op67890'); - expect(results).to.be.an('array').that.is.empty; - }); - - it('throws an error if opportunityId is not provided', async () => { - await expect(suggestionCollectionInstance.allByOpportunityId('')) - .to.be.rejectedWith('OpportunityId is required'); - }); - }); - - describe('allByOpportunityIdAndStatus', () => { - it('returns the suggestions by opportunity and status', async () => { - const mockFindResults = { data: [mockRecord] }; - mockElectroService.entities.suggestion.query.byOpportunityIdAndStatus.returns( - { go: () => Promise.resolve(mockFindResults) }, - ); - - const results = await suggestionCollectionInstance.allByOpportunityIdAndStatus('op67890', 'NEW'); - expect(results).to.be.an('array').that.has.length(1); - expect(results[0]).to.be.instanceOf(Suggestion); - expect(results[0].record).to.deep.include(mockSuggestionModel.record); - }); - - it('returns an empty array if no suggestions exist for the given opportunity ID and status', async () => { - mockElectroService.entities.suggestion.query.byOpportunityIdAndStatus.returns( - { go: () => Promise.resolve([]) }, - ); - - const results = await suggestionCollectionInstance.allByOpportunityIdAndStatus('op67890', 'NEW'); - expect(results).to.be.an('array').that.is.empty; - }); - - it('throws an error if opportunityId is not provided', async () => { - await expect(suggestionCollectionInstance.allByOpportunityIdAndStatus('', 'NEW')) - .to.be.rejectedWith('OpportunityId is required'); - }); - - it('throws an error if status is not provided', async () => { - await expect(suggestionCollectionInstance.allByOpportunityIdAndStatus('op67890', '')) - .to.be.rejectedWith('Status is required'); - }); - }); - - describe('bulkUpdateStatus', () => { - it('updates the status of multiple suggestions', async () => { - const mockSuggestions = [mockSuggestionModel]; - const mockStatus = 'NEW'; - - await suggestionCollectionInstance.bulkUpdateStatus(mockSuggestions, mockStatus); - - expect(mockElectroService.entities.suggestion.put.calledOnce).to.be.true; - expect(mockElectroService.entities.suggestion.put.firstCall.args[0]).to.deep.equal([{ - suggestionId: 's12345', - opportunityId: 'op67890', - data: { - title: 'Test Suggestion', - description: 'This is a test suggestion.', - }, - status: 'NEW', - }]); - }); - - it('throws an error if suggestions is not an array', async () => { - await expect(suggestionCollectionInstance.bulkUpdateStatus({}, 'NEW')) - .to.be.rejectedWith('Suggestions must be an array'); - }); - - it('throws an error if status is not provided', async () => { - await expect(suggestionCollectionInstance.bulkUpdateStatus([mockSuggestionModel], 'foo')) - .to.be.rejectedWith('Invalid status: foo. Must be one of: NEW, APPROVED, SKIPPED, FIXED, ERROR'); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.model.test.js deleted file mode 100644 index a6eb5cf2..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.model.test.js +++ /dev/null @@ -1,156 +0,0 @@ -/* - * 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 { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import Suggestion from '../../../../src/v2/models/suggestion.model.js'; -import SuggestionSchema from '../../../../src/v2/schema/suggestion.schema.js'; - -chaiUse(chaiAsPromised); - -const { attributes } = new Entity(SuggestionSchema).model.schema; - -describe('Suggestion', () => { - let suggestionInstance; - let mockElectroService; - let mockModelFactory; - let mockLogger; - - const mockRecord = { - suggestionId: 'sug12345', - opportunityId: 'op67890', - type: 'CODE_CHANGE', - status: 'NEW', - rank: 1, - data: { - info: 'sample data', - }, - kpiDeltas: { - conversionRate: 0.05, - }, - }; - - beforeEach(() => { - mockElectroService = { - entities: { - suggestion: { - model: { - name: 'suggestion', - schema: { attributes }, - original: { - references: {}, - }, - indexes: { - primary: { - pk: { - field: 'pk', - composite: ['suggestionId'], - }, - }, - }, - }, - patch: stub().returns({ - set: stub(), - }), - }, - }, - }; - - mockModelFactory = { - getCollection: stub(), - }; - - mockLogger = { - error: spy(), - }; - - suggestionInstance = new Suggestion( - mockElectroService, - mockModelFactory, - mockRecord, - mockLogger, - ); - }); - - describe('constructor', () => { - it('initializes the Suggestion instance correctly', () => { - expect(suggestionInstance).to.be.an('object'); - expect(suggestionInstance.record).to.deep.equal(mockRecord); - }); - }); - - describe('getOpportunityId and setOpportunityId', () => { - it('returns the Opportunity ID of the suggestion', () => { - expect(suggestionInstance.getOpportunityId()).to.equal('op67890'); - }); - - it('sets the Opportunity ID of the suggestion', () => { - suggestionInstance.setOpportunityId('ef39921f-9a02-41db-b491-02c98987d956'); - expect(suggestionInstance.record.opportunityId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); - }); - }); - - describe('getType', () => { - it('returns the type of the suggestion', () => { - expect(suggestionInstance.getType()).to.equal('CODE_CHANGE'); - }); - }); - - describe('getStatus and setStatus', () => { - it('returns the status of the suggestion', () => { - expect(suggestionInstance.getStatus()).to.equal('NEW'); - }); - - it('sets the status of the suggestion', () => { - suggestionInstance.setStatus('APPROVED'); - expect(suggestionInstance.record.status).to.equal('APPROVED'); - }); - }); - - describe('getRank and setRank', () => { - it('returns the rank of the suggestion', () => { - expect(suggestionInstance.getRank()).to.equal(1); - }); - - it('sets the rank of the suggestion', () => { - suggestionInstance.setRank(5); - expect(suggestionInstance.record.rank).to.equal(5); - }); - }); - - describe('getData and setData', () => { - it('returns additional data for the suggestion', () => { - expect(suggestionInstance.getData()).to.deep.equal({ info: 'sample data' }); - }); - - it('sets additional data for the suggestion', () => { - suggestionInstance.setData({ newInfo: 'updated data' }); - expect(suggestionInstance.record.data).to.deep.equal({ newInfo: 'updated data' }); - }); - }); - - describe('getKpiDeltas and setKpiDeltas', () => { - it('returns the KPI deltas for the suggestion', () => { - expect(suggestionInstance.getKpiDeltas()).to.deep.equal({ conversionRate: 0.05 }); - }); - - it('sets the KPI deltas for the suggestion', () => { - suggestionInstance.setKpiDeltas({ conversionRate: 0.1 }); - expect(suggestionInstance.record.kpiDeltas).to.deep.equal({ conversionRate: 0.1 }); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.collection.test.js new file mode 100755 index 00000000..989cadb3 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.collection.test.js @@ -0,0 +1,96 @@ +/* + * 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 Suggestion from '../../../../../src/v2/models/suggestion/suggestion.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SuggestionCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + suggestionId: 's12345', + opportunityId: 'op67890', + data: { + title: 'Test Suggestion', + description: 'This is a test suggestion.', + }, + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Suggestion, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the SuggestionCollection 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('bulkUpdateStatus', () => { + it('updates the status of multiple suggestions', async () => { + const mockSuggestions = [model]; + const mockStatus = 'NEW'; + + await instance.bulkUpdateStatus(mockSuggestions, mockStatus); + + expect(mockElectroService.entities.suggestion.put.calledOnce).to.be.true; + expect(mockElectroService.entities.suggestion.put.firstCall.args[0]).to.deep.equal([{ + suggestionId: 's12345', + opportunityId: 'op67890', + data: { + title: 'Test Suggestion', + description: 'This is a test suggestion.', + }, + status: 'NEW', + }]); + }); + + it('throws an error if suggestions is not an array', async () => { + await expect(instance.bulkUpdateStatus({}, 'NEW')) + .to.be.rejectedWith('Suggestions must be an array'); + }); + + it('throws an error if status is not provided', async () => { + await expect(instance.bulkUpdateStatus([model], 'foo')) + .to.be.rejectedWith('Invalid status: foo. Must be one of: NEW, APPROVED, SKIPPED, FIXED, ERROR'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.model.test.js new file mode 100644 index 00000000..921f4673 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.model.test.js @@ -0,0 +1,122 @@ +/* + * 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 Suggestion from '../../../../../src/v2/models/suggestion/suggestion.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SuggestionModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + suggestionId: 'sug12345', + opportunityId: 'op67890', + type: 'CODE_CHANGE', + status: 'NEW', + rank: 1, + data: { + info: 'sample data', + }, + kpiDeltas: { + conversionRate: 0.05, + }, + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Suggestion, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Suggestion instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('getOpportunityId and setOpportunityId', () => { + it('returns the Opportunity ID of the suggestion', () => { + expect(instance.getOpportunityId()).to.equal('op67890'); + }); + + it('sets the Opportunity ID of the suggestion', () => { + instance.setOpportunityId('ef39921f-9a02-41db-b491-02c98987d956'); + expect(instance.record.opportunityId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); + }); + }); + + describe('getType', () => { + it('returns the type of the suggestion', () => { + expect(instance.getType()).to.equal('CODE_CHANGE'); + }); + }); + + describe('getStatus and setStatus', () => { + it('returns the status of the suggestion', () => { + expect(instance.getStatus()).to.equal('NEW'); + }); + + it('sets the status of the suggestion', () => { + instance.setStatus('APPROVED'); + expect(instance.record.status).to.equal('APPROVED'); + }); + }); + + describe('getRank and setRank', () => { + it('returns the rank of the suggestion', () => { + expect(instance.getRank()).to.equal(1); + }); + + it('sets the rank of the suggestion', () => { + instance.setRank(5); + expect(instance.record.rank).to.equal(5); + }); + }); + + describe('getData and setData', () => { + it('returns additional data for the suggestion', () => { + expect(instance.getData()).to.deep.equal({ info: 'sample data' }); + }); + + it('sets additional data for the suggestion', () => { + instance.setData({ newInfo: 'updated data' }); + expect(instance.record.data).to.deep.equal({ newInfo: 'updated data' }); + }); + }); + + describe('getKpiDeltas and setKpiDeltas', () => { + it('returns the KPI deltas for the suggestion', () => { + expect(instance.getKpiDeltas()).to.deep.equal({ conversionRate: 0.05 }); + }); + + it('sets the KPI deltas for the suggestion', () => { + instance.setKpiDeltas({ conversionRate: 0.1 }); + expect(instance.record.kpiDeltas).to.deep.equal({ conversionRate: 0.1 }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util.js b/packages/spacecat-shared-data-access/test/unit/v2/util.js new file mode 100755 index 00000000..d9587461 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/util.js @@ -0,0 +1,107 @@ +/* + * 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 { Entity } from 'electrodb'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { spy, stub } from 'sinon'; + +import EntityRegistry from '../../../src/v2/models/base/entity.registry.js'; +import { modelNameToEntityName } from '../../../src/v2/util/util.js'; + +export const createElectroMocks = (Model, record) => { + const entityName = modelNameToEntityName(Model.name); + const { + schema, + collection: Collection, + } = EntityRegistry.entities[modelNameToEntityName(Model.name)]; + const entity = new Entity(schema.toElectroDBSchema()); + + const mockLogger = { + debug: spy(), + error: spy(), + info: spy(), + warn: spy(), + }; + + const mockOperations = { + create: stub().returns({ + go: stub().resolves({ data: record }), + }), + delete: stub().returns({ + go: stub().resolves({}), + }), + patch: stub().returns({ + set: stub(), + }), + put: stub().returns({ + go: stub().resolves({}), + }), + query: { + all: stub().returns({ + between: stub().returns({ + go: () => ({ data: [] }), + }), + go: () => ({ data: [] }), + }), + bySomeKey: stub(), + primary: stub(), + byOpportunityId: stub(), + byOpportunityIdAndStatus: stub(), + }, + }; + + const mockEntityRegistry = { + log: mockLogger, + getCollection: stub().returns({ + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: stub().returns(Model.name), + indexes: { + primaryIndex: { + pk: { facets: ['testEntityId'] }, + sk: { facets: ['name', 'age'] }, + }, + }, + }, + }), + }; + + const mockElectroService = { + entities: { + [entityName]: { ...entity, ...mockOperations }, + }, + }; + + const model = new Model( + mockElectroService, + mockEntityRegistry, + schema, + record, + mockLogger, + ); + + const collection = new Collection( + mockElectroService, + mockEntityRegistry, + schema, + mockLogger, + ); + + return { + mockElectroService, + mockLogger, + mockEntityRegistry, + collection, + model, + schema, + }; +}; diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util/accessor.utils.test.js b/packages/spacecat-shared-data-access/test/unit/v2/util/accessor.utils.test.js new file mode 100755 index 00000000..3ec7872c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/util/accessor.utils.test.js @@ -0,0 +1,222 @@ +/* + * 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. + */ +/* + * 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 sinon, { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { createAccessor } from '../../../../src/v2/util/accessor.utils.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('Accessor Utils', () => { /* eslint-disable no-underscore-dangle */ + let mockLogger; + let mockContext; + let mockCollection; + + beforeEach(() => { + mockLogger = { + debug: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + }; + + mockContext = { log: mockLogger }; + + mockCollection = { + allByIndexKeys: stub().returns(Promise.resolve([{}])), + findById: stub().returns(Promise.resolve({})), + findByIndexKeys: stub().returns(Promise.resolve({})), + schema: { + getAttribute: stub().returns({ type: 'string' }), + }, + }; + }); + + describe('createAccessor', () => { + it('throws an error if no config is provided', () => { + expect(() => createAccessor()).to.throw('Config is required'); + expect(() => createAccessor([])).to.throw('Config is required'); + }); + + it('throws an error if collection is not provided', () => { + expect(() => createAccessor({ a: 1 })).to.throw('Collection is required'); + }); + + it('throws an error if context is not provided', () => { + expect(() => createAccessor({ collection: { a: 1 } })).to.throw('Context is required'); + }); + + it('throws an error if name is not provided', () => { + expect(() => createAccessor({ collection: { a: 1 }, context: { a: 1 } })).to.throw('Name is required'); + }); + + it('throws and error if requiredKeys is not an array', () => { + expect(() => createAccessor({ + collection: { a: 1 }, context: { a: 1 }, name: 'test', requiredKeys: 'test', + })).to.throw('Required keys must be an array'); + }); + + it('creates an accessor from config', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + }; + + createAccessor(config); + + expect(mockContext.test).to.be.a('function'); + expect(mockContext.test()).to.be.an('Promise'); + expect(mockContext._accessorCache).to.deep.equal({}); + }); + + it('does not create an accessor cache if existing', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + }; + mockContext._accessorCache = { a: 1 }; + + createAccessor(config); + + expect(mockContext._accessorCache).to.deep.equal({ a: 1 }); + }); + }); + + describe('call accessor', () => { + it('calling accessor calls findByIndexKeys', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + }; + + createAccessor(config); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal({}); + expect(mockCollection.schema.getAttribute).to.have.been.calledOnceWith('test'); + expect(mockCollection.findByIndexKeys).to.have.been.calledOnceWith({ test: 'test' }); + }); + + it('calling accessor calls allByIndexKeys', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + all: true, + }; + + createAccessor(config); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal([{}]); + expect(mockCollection.schema.getAttribute).to.have.been.calledOnceWith('test'); + expect(mockCollection.allByIndexKeys).to.have.been.calledOnceWith({ test: 'test' }); + }); + + it('calling accessor calls findBYId', async () => { + const config = { + collection: mockCollection, + context: mockContext, + foreignKey: { name: 'test', value: 'test' }, + name: 'test', + requiredKeys: ['test'], + byId: true, + }; + + createAccessor(config); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal({}); + expect(mockCollection.schema.getAttribute).to.not.have.been.called; + expect(mockCollection.findById).to.have.been.calledOnceWith('test'); + }); + + it('returns null when calling accessor byId with no value', async () => { + const config = { + collection: mockCollection, + context: mockContext, + foreignKey: { name: 'test' }, + name: 'test', + requiredKeys: ['test'], + byId: true, + }; + + createAccessor(config); + + await expect(mockContext.test('test')).to.be.eventually.null; + expect(mockCollection.schema.getAttribute).to.not.have.been.called; + expect(mockCollection.findById).to.not.have.been.called; + }); + + it('returns cached result if repeatedly called without args', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: [], + }; + + createAccessor(config); + + expect(mockContext._accessorCache).to.deep.equal({}); + + await expect(mockContext.test()).to.be.eventually.deep.equal({}); + + expect(mockContext._accessorCache).to.deep.equal({ 'test:_': {} }); + + await expect(mockContext.test()).to.be.eventually.deep.equal({}); + expect(mockCollection.schema.getAttribute).to.not.have.been.called; + expect(mockCollection.findByIndexKeys).to.have.been.calledOnceWith({}); + }); + + it('returns cached result if repeatedly called with same args', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + }; + + createAccessor(config); + + expect(mockContext._accessorCache).to.deep.equal({}); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal({}); + + expect(mockContext._accessorCache).to.deep.equal({ 'test:["test"]': {} }); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal({}); + expect(mockCollection.schema.getAttribute).to.have.been.calledOnceWith('test'); + expect(mockCollection.findByIndexKeys).to.have.been.calledOnceWith({ test: 'test' }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util/guards.test.js b/packages/spacecat-shared-data-access/test/unit/v2/util/guards.test.js index 9f67c1a8..f6a6a93f 100644 --- a/packages/spacecat-shared-data-access/test/unit/v2/util/guards.test.js +++ b/packages/spacecat-shared-data-access/test/unit/v2/util/guards.test.js @@ -18,6 +18,7 @@ import chaiAsPromised from 'chai-as-promised'; import { guardAny, guardArray, + guardBoolean, guardEnum, guardId, guardMap, @@ -70,13 +71,13 @@ describe('Guards', () => { }); it('allows specifying type as object', () => { - expect(() => guardArray('testProperty', [{ key: 'value' }, { anotherKey: 'anotherValue' }], 'TestEntity', 'object')) + expect(() => guardArray('testProperty', [{ key: 'value' }, { anotherKey: 'anotherValue' }], 'TestEntity', 'map')) .not.to.throw(); }); it('throws an error if array contains wrong type when expecting objects', () => { - expect(() => guardArray('testProperty', [{ key: 'value' }, 'notAnObject'], 'TestEntity', 'object')) - .to.throw('Validation failed in TestEntity: testProperty must contain items of type object'); + expect(() => guardArray('testProperty', [{ key: 'value' }, 'notAnObject'], 'TestEntity', 'map')) + .to.throw('Validation failed in TestEntity: testProperty must contain items of type map'); }); it('throws an error if an unsupported type is specified', () => { @@ -85,6 +86,43 @@ describe('Guards', () => { }); }); + describe('guardBoolean', () => { + it('throws an error if value is not a boolean', () => { + expect(() => guardBoolean('testProperty', 'notABoolean', 'TestEntity')) + .to.throw('Validation failed in TestEntity: testProperty must be a boolean'); + }); + + it('does not throw if value is a boolean', () => { + expect(() => guardBoolean('testProperty', true, 'TestEntity')) + .not.to.throw(); + }); + + it('does not throw if value is null and nullable is true', () => { + expect(() => guardBoolean('testProperty', null, 'TestEntity', true)) + .not.to.throw(); + }); + + it('does not throw if value is undefined and nullable is true', () => { + expect(() => guardBoolean('testProperty', undefined, 'TestEntity', true)) + .not.to.throw(); + }); + + it('throws an error if value is undefined and nullable is false', () => { + expect(() => guardBoolean('testProperty', undefined, 'TestEntity', false)) + .to.throw('Validation failed in TestEntity: testProperty must be a boolean'); + }); + + it('throws an error if value is null and nullable is false', () => { + expect(() => guardBoolean('testProperty', null, 'TestEntity', false)) + .to.throw('Validation failed in TestEntity: testProperty must be a boolean'); + }); + + it('throws an error if value is an empty string and nullable is false', () => { + expect(() => guardBoolean('testProperty', '', 'TestEntity', false)) + .to.throw('Validation failed in TestEntity: testProperty must be a boolean'); + }); + }); + describe('guardSet', () => { it('throws an error if value is not an array', () => { expect(() => guardSet('testProperty', 'notArray', 'TestEntity')) @@ -139,8 +177,8 @@ describe('Guards', () => { }); it('throws an error if array contains wrong type when expecting objects', () => { - expect(() => guardSet('testProperty', [{}, { a: 'b' }, 3], 'TestEntity', 'object')) - .to.throw('Validation failed in TestEntity: testProperty must contain items of type object'); + expect(() => guardSet('testProperty', [{}, { a: 'b' }, 3], 'TestEntity', 'map')) + .to.throw('Validation failed in TestEntity: testProperty must contain items of type map'); }); it('throws an error if array contains wrong type when expecting strings', () => { diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js b/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js index 31673b14..6d57a585 100755 --- a/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js +++ b/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js @@ -12,14 +12,23 @@ /* eslint-env mocha */ +// eslint-disable-next-line max-classes-per-file +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + import { expect, use as chaiUse } from 'chai'; import sinon from 'sinon'; import chaiAsPromised from 'chai-as-promised'; import Patcher from '../../../../src/v2/util/patcher.js'; +import Schema from '../../../../src/v2/models/base/schema.js'; +import BaseModel from '../../../../src/v2/models/base/base.model.js'; +import BaseCollection from '../../../../src/v2/models/base/base.collection.js'; chaiUse(chaiAsPromised); +const MockModel = class MockEntityModel extends BaseModel {}; +const MockCollection = class MockEntityCollection extends BaseCollection {}; + describe('Patcher', () => { let patcher; let mockEntity; @@ -28,18 +37,19 @@ describe('Patcher', () => { beforeEach(() => { mockEntity = { model: { - name: 'TestEntity', + entity: 'MockModel', schema: { attributes: { - name: { type: 'string' }, - age: { type: 'number' }, - tags: { type: 'set', items: { type: 'string' } }, - status: { type: 'enum', enumArray: ['active', 'inactive'] }, - referenceId: { type: 'string' }, - metadata: { type: 'map' }, - profile: { type: 'any' }, - nickNames: { type: 'list', items: { type: 'string' } }, - settings: { type: 'any', required: true }, + name: { type: 'string', name: 'name' }, + age: { type: 'number', name: 'age' }, + tags: { type: 'set', name: 'tags', items: { type: 'string' } }, + status: { type: 'enum', name: 'status', enumArray: ['active', 'inactive'] }, + referenceId: { type: 'string', name: 'referenceId' }, + metadata: { type: 'map', name: 'metadata' }, + profile: { type: 'any', name: 'profile' }, + nickNames: { type: 'list', name: 'nickNames', items: { type: 'string' } }, + settings: { type: 'any', name: 'settings', required: true }, + isActive: { type: 'boolean', name: 'isActive' }, }, }, indexes: { @@ -50,6 +60,7 @@ describe('Patcher', () => { }, }, patch: sinon.stub().returns({ + composite: sinon.stub().returnsThis(), set: sinon.stub().returnsThis(), go: sinon.stub().resolves(), }), @@ -64,7 +75,20 @@ describe('Patcher', () => { referenceId: '456', }; - patcher = new Patcher(mockEntity, mockRecord); + const schema = new Schema( + MockModel, + MockCollection, + { + serviceName: 'service', + schemaVersion: 1, + attributes: mockEntity.model.schema.attributes, + indexes: mockEntity.model.indexes, + model: mockEntity.model, + references: [], + }, + ); + + patcher = new Patcher(mockEntity, schema, mockRecord); }); afterEach(() => { @@ -73,7 +97,7 @@ describe('Patcher', () => { it('patches a string value', () => { patcher.patchValue('name', 'UpdatedName'); - expect(mockEntity.patch().set.calledWith({ name: 'UpdatedName', age: 25 })).to.be.true; + expect(mockEntity.patch().set.calledWith({ name: 'UpdatedName' })).to.be.true; expect(mockRecord.name).to.equal('UpdatedName'); }); @@ -90,7 +114,7 @@ describe('Patcher', () => { it('throws error for unsupported enum value', () => { expect(() => patcher.patchValue('status', 'unknown')) - .to.throw('Validation failed in testentity: status must be one of active,inactive'); + .to.throw('Validation failed in mockEntityModel: status must be one of active,inactive'); }); it('patches a reference id with proper validation', () => { @@ -100,14 +124,15 @@ describe('Patcher', () => { it('throws error for non-existent property', () => { expect(() => patcher.patchValue('nonExistent', 'value')) - .to.throw('Property nonExistent does not exist on entity testentity.'); + .to.throw('Property nonExistent does not exist on entity mockEntityModel.'); }); it('tracks updates', () => { patcher.patchValue('name', 'UpdatedName'); expect(patcher.hasUpdates()).to.be.true; - expect(patcher.getUpdates()).to.deep.equal({ name: 'UpdatedName' }); + expect(patcher.getUpdates().name.previous).to.deep.equal('Test'); + expect(patcher.getUpdates().name.current).to.deep.equal('UpdatedName'); }); it('saves the record', async () => { @@ -116,7 +141,7 @@ describe('Patcher', () => { await patcher.save(); expect(mockEntity.patch().go.calledOnce).to.be.true; - expect(mockRecord.updatedAt).to.be.a('number'); + expect(isIsoDate(mockRecord.updatedAt)).to.be.true; }); it('does not save if there are no updates', async () => { @@ -137,7 +162,7 @@ describe('Patcher', () => { it('throws error for invalid set attribute', () => { expect(() => patcher.patchValue('tags', ['tag1', 123])) - .to.throw('Validation failed in testentity: tags must contain items of type string'); + .to.throw('Validation failed in mockEntityModel: tags must contain items of type string'); }); it('validates and patches a number attribute', () => { @@ -147,7 +172,7 @@ describe('Patcher', () => { it('throws error for invalid number attribute', () => { expect(() => patcher.patchValue('age', 'notANumber')) - .to.throw('Validation failed in testentity: age must be a number'); + .to.throw('Validation failed in mockEntityModel: age must be a number'); }); it('validates and patch a map attribute', () => { @@ -157,7 +182,7 @@ describe('Patcher', () => { it('throws error for invalid map attribute', () => { expect(() => patcher.patchValue('metadata', 'notAMap')) - .to.throw('Validation failed in testentity: metadata must be an object'); + .to.throw('Validation failed in mockEntityModel: metadata must be an object'); }); it('validates and patches an any attribute', () => { @@ -167,12 +192,17 @@ describe('Patcher', () => { it('throws error for undefined any attribute', () => { expect(() => patcher.patchValue('settings', undefined)) - .to.throw('Validation failed in testentity: settings is required'); + .to.throw('Validation failed in mockEntityModel: settings is required'); }); it('throws error for null any attribute', () => { expect(() => patcher.patchValue('settings', null)) - .to.throw('Validation failed in testentity: settings is required'); + .to.throw('Validation failed in mockEntityModel: settings is required'); + }); + + it('validates and patches a boolean attribute', () => { + patcher.patchValue('isActive', true); + expect(mockRecord.isActive).to.be.true; }); it('validates and patches a list attribute', () => { @@ -182,11 +212,11 @@ describe('Patcher', () => { it('throws error for invalid list attribute', () => { expect(() => patcher.patchValue('nickNames', 'notAList')) - .to.throw('Validation failed in testentity: nickNames must be an array'); + .to.throw('Validation failed in mockEntityModel: nickNames must be an array'); }); it('throws error for invalid list attribute items', () => { expect(() => patcher.patchValue('nickNames', ['name1', 123])) - .to.throw('Validation failed in testentity: nickNames must contain items of type string'); + .to.throw('Validation failed in mockEntityModel: nickNames must contain items of type string'); }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util/util.test.js b/packages/spacecat-shared-data-access/test/unit/v2/util/util.test.js new file mode 100644 index 00000000..edfdaa77 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/util/util.test.js @@ -0,0 +1,220 @@ +/* + * 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 */ + +// utils.test.js +// This suite tests all utility functions from the provided utils file. +// Requires Mocha for tests, Chai for assertions, and Sinon for spying/stubbing. + +import { expect } from 'chai'; +import { + capitalize, + collectionNameToEntityName, + decapitalize, + entityNameToAllPKValue, + entityNameToCollectionName, + entityNameToIdName, + idNameToEntityName, + incrementVersion, + isNonEmptyArray, + keyNamesToIndexName, + modelNameToEntityName, + referenceToBaseMethodName, + sanitizeIdAndAuditFields, + sanitizeTimestamps, +} from '../../../../src/v2/util/util.js'; +import Reference from '../../../../src/v2/models/base/reference.js'; + +describe('Utilities', () => { + describe('capitalize', () => { + it('Convert first character to uppercase', () => { + expect(capitalize('hello')).to.equal('Hello'); + }); + + it('Return empty string if input empty', () => { + expect(capitalize('')).to.equal(''); + }); + + it('Not alter already capitalized strings', () => { + expect(capitalize('Hello')).to.equal('Hello'); + }); + }); + + describe('decapitalize', () => { + it('Convert first character to lowercase', () => { + expect(decapitalize('Hello')).to.equal('hello'); + }); + + it('Return empty string if input empty', () => { + expect(decapitalize('')).to.equal(''); + }); + + it('Not alter already lowercased strings', () => { + expect(decapitalize('hello')).to.equal('hello'); + }); + }); + + describe('collectionNameToEntityName', () => { + it('Remove "Collection" suffix from a given string', () => { + expect(collectionNameToEntityName('UserCollection')).to.equal('User'); + }); + + it('Return the original string if no "Collection" present', () => { + expect(collectionNameToEntityName('User')).to.equal('User'); + }); + }); + + describe('entityNameToCollectionName', () => { + it('Append "Collection" to a singular form of entity name', () => { + expect(entityNameToCollectionName('User')).to.equal('UserCollection'); + }); + + it('Handle plural entity names by converting to singular first', () => { + expect(entityNameToCollectionName('Users')).to.equal('UserCollection'); + }); + }); + + describe('entityNameToIdName', () => { + it('Convert entityName to a lowercaseId format', () => { + expect(entityNameToIdName('User')).to.equal('userId'); + }); + + it('Handle already lowercase entityName', () => { + expect(entityNameToIdName('user')).to.equal('userId'); + }); + }); + + describe('entityNameToAllPKValue', () => { + it('Convert entity name to ALL_ upper plural form', () => { + expect(entityNameToAllPKValue('User')).to.equal('ALL_USERS'); + }); + + it('Handle already plural entity name', () => { + expect(entityNameToAllPKValue('Users')).to.equal('ALL_USERS'); + }); + }); + + describe('referenceToBaseMethodName', () => { + it('Generate "get" + pluralized capitalized target if type is has_many', () => { + const reference = new Reference('has_many', 'users'); + expect(referenceToBaseMethodName(reference)).to.equal('getUsers'); + }); + + it('Generate "get" + singular capitalized target if type is not has_many', () => { + const reference = new Reference('has_one', 'users'); + expect(referenceToBaseMethodName(reference)).to.equal('getUser'); + }); + + it('Handle already capitalized target', () => { + const reference = new Reference('has_many', 'User'); + expect(referenceToBaseMethodName(reference)).to.equal('getUsers'); + }); + }); + + describe('idNameToEntityName', () => { + it('Convert idName to singular, capitalized entityName', () => { + expect(idNameToEntityName('userId')).to.equal('User'); + }); + + it('Handle plural-like idNames', () => { + expect(idNameToEntityName('usersId')).to.equal('User'); + }); + }); + + describe('incrementVersion', () => { + it('Increment version by 1 if it is an integer', () => { + expect(incrementVersion(1)).to.equal(2); + }); + + it('Return 1 if version is not an integer', () => { + expect(incrementVersion('not-a-number')).to.equal(1); + }); + + it('Return 1 if version is undefined', () => { + expect(incrementVersion(undefined)).to.equal(1); + }); + }); + + describe('isNonEmptyArray', () => { + it('Return true if value is a non-empty array', () => { + expect(isNonEmptyArray([1, 2, 3])).to.be.true; + }); + + it('Return false if value is an empty array', () => { + expect(isNonEmptyArray([])).to.be.false; + }); + + it('Return false if value is not an array', () => { + expect(isNonEmptyArray({})).to.be.false; + }); + }); + + describe('keyNamesToIndexName', () => { + it('Create index name by capitalizing and joining key names', () => { + expect(keyNamesToIndexName(['user', 'status'])).to.equal('byUserAndStatus'); + }); + + it('Handle single key name', () => { + expect(keyNamesToIndexName(['user'])).to.equal('byUser'); + }); + }); + + describe('modelNameToEntityName', () => { + it('Decapitalize model name', () => { + expect(modelNameToEntityName('UserModel')).to.equal('userModel'); + }); + + it('Handle already lowercase', () => { + expect(modelNameToEntityName('usermodel')).to.equal('usermodel'); + }); + }); + + describe('sanitizeTimestamps', () => { + it('Remove createdAt and updatedAt fields', () => { + const data = { foo: 'bar', createdAt: 'yesterday', updatedAt: 'today' }; + expect(sanitizeTimestamps(data)).to.deep.equal({ foo: 'bar' }); + }); + + it('Return object unchanged if no timestamps present', () => { + const data = { foo: 'bar' }; + expect(sanitizeTimestamps(data)).to.deep.equal({ foo: 'bar' }); + }); + }); + + describe('sanitizeIdAndAuditFields', () => { + it('Remove entity ID and timestamps', () => { + const data = { + userId: '123', + foo: 'bar', + createdAt: 'yesterday', + updatedAt: 'today', + }; + expect(sanitizeIdAndAuditFields('User', data)).to.deep.equal({ foo: 'bar' }); + }); + + it('Handle entityName that results in different idName', () => { + const data = { + productId: 'abc', + name: 'Gadget', + createdAt: 'yesterday', + updatedAt: 'today', + }; + expect(sanitizeIdAndAuditFields('Product', data)).to.deep.equal({ name: 'Gadget' }); + }); + + it('Return object unchanged if no ID or timestamps present', () => { + const data = { foo: 'bar' }; + expect(sanitizeIdAndAuditFields('User', data)).to.deep.equal({ foo: 'bar' }); + }); + }); +}); diff --git a/packages/spacecat-shared-dynamo/package.json b/packages/spacecat-shared-dynamo/package.json index 5d0409ab..ab3dcd4d 100644 --- a/packages/spacecat-shared-dynamo/package.json +++ b/packages/spacecat-shared-dynamo/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-example/package.json b/packages/spacecat-shared-example/package.json index f9ed8f4d..84286e46 100644 --- a/packages/spacecat-shared-example/package.json +++ b/packages/spacecat-shared-example/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/example-wrapper.js", "types": "src/example-wrapper.d.ts", diff --git a/packages/spacecat-shared-google-client/package.json b/packages/spacecat-shared-google-client/package.json index 025154cb..0087047a 100644 --- a/packages/spacecat-shared-google-client/package.json +++ b/packages/spacecat-shared-google-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-gpt-client/package.json b/packages/spacecat-shared-gpt-client/package.json index f4b84caa..bebea29f 100644 --- a/packages/spacecat-shared-gpt-client/package.json +++ b/packages/spacecat-shared-gpt-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-http-utils/CHANGELOG.md b/packages/spacecat-shared-http-utils/CHANGELOG.md index 3c1be0bf..74bfb573 100644 --- a/packages/spacecat-shared-http-utils/CHANGELOG.md +++ b/packages/spacecat-shared-http-utils/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@adobe/spacecat-shared-http-utils-v1.8.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-http-utils-v1.7.3...@adobe/spacecat-shared-http-utils-v1.8.0) (2024-12-18) + + +### Features + +* migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) + # [@adobe/spacecat-shared-http-utils-v1.7.3](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-http-utils-v1.7.2...@adobe/spacecat-shared-http-utils-v1.7.3) (2024-12-07) diff --git a/packages/spacecat-shared-http-utils/package.json b/packages/spacecat-shared-http-utils/package.json index 2f67f8fc..9f6ce6b1 100644 --- a/packages/spacecat-shared-http-utils/package.json +++ b/packages/spacecat-shared-http-utils/package.json @@ -1,11 +1,11 @@ { "name": "@adobe/spacecat-shared-http-utils", - "version": "1.7.3", + "version": "1.8.0", "description": "Shared modules of the Spacecat Services - HTTP Utils", "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-http-utils/src/auth/authentication-manager.js b/packages/spacecat-shared-http-utils/src/auth/authentication-manager.js index c835c9f3..929ba913 100644 --- a/packages/spacecat-shared-http-utils/src/auth/authentication-manager.js +++ b/packages/spacecat-shared-http-utils/src/auth/authentication-manager.js @@ -72,7 +72,7 @@ export default class AuthenticationManager { /** * Create an instance of AuthenticationManager. * @param {Array} handlers - The handlers to be used for authentication - * @param {Object} log - The logger object + * @param {Object} log - The log object * @return {AuthenticationManager} The authentication manager */ static create(handlers, log) { diff --git a/packages/spacecat-shared-http-utils/test/auth/handlers/abstract.test.js b/packages/spacecat-shared-http-utils/test/auth/handlers/abstract.test.js index 44cbe0a3..69f840f8 100644 --- a/packages/spacecat-shared-http-utils/test/auth/handlers/abstract.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/handlers/abstract.test.js @@ -47,7 +47,7 @@ describe('AbstractHandler', () => { expect(() => new AbstractHandler('TestHandler', logStub)).to.throw(TypeError, 'Cannot construct AbstractHandler instances directly'); }); - it('sets the name and logger properties correctly', () => { + it('sets the name and log properties correctly', () => { const handler = new ConcreteHandler(logStub); expect(handler.name).to.equal('ConcreteHandler'); expect(handler.logger).to.equal(logStub); diff --git a/packages/spacecat-shared-http-utils/test/auth/handlers/ims.test.js b/packages/spacecat-shared-http-utils/test/auth/handlers/ims.test.js index 8b75cbd5..a07aa1db 100644 --- a/packages/spacecat-shared-http-utils/test/auth/handlers/ims.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/handlers/ims.test.js @@ -58,7 +58,7 @@ describe('AdobeImsHandler', () => { expect(handler).to.be.instanceof(AbstractHandler); }); - it('sets the name and logger properties correctly', () => { + it('sets the name and log properties correctly', () => { expect(handler.name).to.equal('ims'); expect(handler.logger).to.equal(logStub); }); diff --git a/packages/spacecat-shared-http-utils/test/auth/handlers/legacy-api-keys.test.js b/packages/spacecat-shared-http-utils/test/auth/handlers/legacy-api-keys.test.js index f5d2ff65..53cdd03c 100644 --- a/packages/spacecat-shared-http-utils/test/auth/handlers/legacy-api-keys.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/handlers/legacy-api-keys.test.js @@ -56,7 +56,7 @@ describe('LegacyApiKeyHandler', () => { expect(handler).to.be.instanceof(AbstractHandler); }); - it('should set the name and logger properties correctly', () => { + it('should set the name and log properties correctly', () => { expect(handler.name).to.equal('legacyApiKey'); expect(handler.logger).to.equal(logStub); }); diff --git a/packages/spacecat-shared-http-utils/test/auth/handlers/scoped-api-key.test.js b/packages/spacecat-shared-http-utils/test/auth/handlers/scoped-api-key.test.js index 92690e43..e7b9829e 100644 --- a/packages/spacecat-shared-http-utils/test/auth/handlers/scoped-api-key.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/handlers/scoped-api-key.test.js @@ -73,7 +73,7 @@ describe('ScopedApiKeyHandler', () => { expect(handler).to.be.instanceof(AbstractHandler); }); - it('should set the name and logger properties correctly', () => { + it('should set the name and log properties correctly', () => { expect(handler.name).to.equal('scopedApiKey'); expect(handler.logger).to.equal(logStub); }); diff --git a/packages/spacecat-shared-ims-client/CHANGELOG.md b/packages/spacecat-shared-ims-client/CHANGELOG.md index c130aef6..7c26c098 100644 --- a/packages/spacecat-shared-ims-client/CHANGELOG.md +++ b/packages/spacecat-shared-ims-client/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@adobe/spacecat-shared-ims-client-v1.4.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-ims-client-v1.3.27...@adobe/spacecat-shared-ims-client-v1.4.0) (2024-12-18) + + +### Features + +* migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) + # [@adobe/spacecat-shared-ims-client-v1.3.27](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-ims-client-v1.3.26...@adobe/spacecat-shared-ims-client-v1.3.27) (2024-12-08) diff --git a/packages/spacecat-shared-ims-client/README.md b/packages/spacecat-shared-ims-client/README.md index 7ae53b0d..716ce226 100644 --- a/packages/spacecat-shared-ims-client/README.md +++ b/packages/spacecat-shared-ims-client/README.md @@ -25,7 +25,7 @@ import ImsClient from 'path/to/ImsClient'; ### Creating an ImsClient Instance -To create an instance of the ImsClient, you need to provide a context object containing the necessary environment configurations and an optional logger. +To create an instance of the ImsClient, you need to provide a context object containing the necessary environment configurations and an optional log. ```javascript const context = { @@ -35,7 +35,7 @@ const context = { IMS_CLIENT_CODE: 'yourClientCode', IMS_CLIENT_SECRET: 'yourClientSecret', }, - log: console, // Optional: Custom logger can be provided + log: console, // Optional: Custom log can be provided }; const imsClient = ImsClient.createFrom(context); diff --git a/packages/spacecat-shared-ims-client/package.json b/packages/spacecat-shared-ims-client/package.json index 654de6e5..e3e1f331 100644 --- a/packages/spacecat-shared-ims-client/package.json +++ b/packages/spacecat-shared-ims-client/package.json @@ -1,11 +1,11 @@ { "name": "@adobe/spacecat-shared-ims-client", - "version": "1.3.27", + "version": "1.4.0", "description": "Shared modules of the Spacecat Services - IMS Client", "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-rum-api-client/package.json b/packages/spacecat-shared-rum-api-client/package.json index 93fc8c8d..05bfd7b7 100644 --- a/packages/spacecat-shared-rum-api-client/package.json +++ b/packages/spacecat-shared-rum-api-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-slack-client/CHANGELOG.md b/packages/spacecat-shared-slack-client/CHANGELOG.md index feb246ac..a7f5ddab 100644 --- a/packages/spacecat-shared-slack-client/CHANGELOG.md +++ b/packages/spacecat-shared-slack-client/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@adobe/spacecat-shared-slack-client-v1.4.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-slack-client-v1.3.28...@adobe/spacecat-shared-slack-client-v1.4.0) (2024-12-18) + + +### Features + +* migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) + # [@adobe/spacecat-shared-slack-client-v1.3.28](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-slack-client-v1.3.27...@adobe/spacecat-shared-slack-client-v1.3.28) (2024-12-08) diff --git a/packages/spacecat-shared-slack-client/package.json b/packages/spacecat-shared-slack-client/package.json index e6ce7655..d6bb9919 100644 --- a/packages/spacecat-shared-slack-client/package.json +++ b/packages/spacecat-shared-slack-client/package.json @@ -1,11 +1,11 @@ { "name": "@adobe/spacecat-shared-slack-client", - "version": "1.3.28", + "version": "1.4.0", "description": "Shared modules of the Spacecat Services - Slack Client", "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-slack-client/src/clients/base-slack-client.js b/packages/spacecat-shared-slack-client/src/clients/base-slack-client.js index b339eabf..261127e0 100644 --- a/packages/spacecat-shared-slack-client/src/clients/base-slack-client.js +++ b/packages/spacecat-shared-slack-client/src/clients/base-slack-client.js @@ -79,7 +79,7 @@ export default class BaseSlackClient { * @param {object} opsConfig The ops configuration. * @param {string} opsConfig.opsChannelId The ID of the ops channel. * @param {string[]} opsConfig.admins The list of admin user IDs. - * @param {object} log - logger + * @param {object} log - log */ constructor(token, opsConfig, log) { this.client = new WebClient(token); diff --git a/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js b/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js index b7f612f6..4382181b 100644 --- a/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js +++ b/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js @@ -59,7 +59,7 @@ export default class ElevatedSlackClient extends BaseSlackClient { * @param {object} opsConfig The ops configuration. * @param {string} opsConfig.opsChannelId The ID of the ops channel. * @param {string[]} opsConfig.admins The list of admin user IDs. - * @param {Object} log The logger object. + * @param {Object} log The log object. */ constructor(token, opsConfig, log) { super(token, opsConfig, log); diff --git a/packages/spacecat-shared-utils/package.json b/packages/spacecat-shared-utils/package.json index afd65a06..990146bb 100644 --- a/packages/spacecat-shared-utils/package.json +++ b/packages/spacecat-shared-utils/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts",