diff --git a/package-lock.json b/package-lock.json index 8db16a1db..3c11f0b1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1027,7 +1027,6 @@ "node_modules/@aws-sdk/client-dynamodb": { "version": "3.893.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1922,7 +1921,6 @@ "node_modules/@aws-sdk/client-sso-oidc": { "version": "3.726.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2340,7 +2338,6 @@ "node_modules/@aws-sdk/client-sts": { "version": "3.726.1", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2950,7 +2947,6 @@ "version": "7.28.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3758,7 +3754,6 @@ "version": "7.0.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", @@ -5306,7 +5301,6 @@ "version": "14.1.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -5439,7 +5433,6 @@ "version": "8.44.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -5647,7 +5640,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5938,7 +5930,6 @@ "node_modules/aws-xray-sdk-core": { "version": "3.10.3", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -6135,7 +6126,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -6318,7 +6308,6 @@ "version": "6.0.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -7974,7 +7963,6 @@ "version": "9.36.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10901,7 +10889,6 @@ "version": "4.3.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -14462,7 +14449,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15920,7 +15906,6 @@ "version": "4.52.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -16057,7 +16042,6 @@ "version": "24.2.9", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -16796,7 +16780,6 @@ "version": "21.0.0", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^13.0.5", @@ -17601,7 +17584,6 @@ "version": "5.9.2", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17689,7 +17671,6 @@ "node_modules/unified": { "version": "11.0.5", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -19291,7 +19272,6 @@ "packages/spacecat-shared-ahrefs-client/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -20962,7 +20942,6 @@ "packages/spacecat-shared-athena-client/node_modules/@aws-sdk/client-dynamodb": { "version": "3.859.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -23197,7 +23176,6 @@ "packages/spacecat-shared-athena-client/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -25145,7 +25123,6 @@ "packages/spacecat-shared-brand-client/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -26594,7 +26571,6 @@ "packages/spacecat-shared-content-client/node_modules/@aws-sdk/client-dynamodb": { "version": "3.859.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -28379,7 +28355,7 @@ }, "packages/spacecat-shared-data-access": { "name": "@adobe/spacecat-shared-data-access", - "version": "2.71.0", + "version": "2.71.1", "license": "Apache-2.0", "dependencies": { "@adobe/spacecat-shared-utils": "1.49.0", @@ -32177,7 +32153,6 @@ "packages/spacecat-shared-data-access/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -32335,7 +32310,7 @@ }, "packages/spacecat-shared-google-client": { "name": "@adobe/spacecat-shared-google-client", - "version": "1.4.49", + "version": "1.4.50", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.2.3", @@ -32674,7 +32649,6 @@ "packages/spacecat-shared-google-client/node_modules/@adobe/spacecat-shared-data-access/node_modules/@aws-sdk/client-sso-oidc": { "version": "3.716.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -32726,7 +32700,6 @@ "packages/spacecat-shared-google-client/node_modules/@adobe/spacecat-shared-data-access/node_modules/@aws-sdk/client-sts": { "version": "3.716.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -33829,7 +33802,6 @@ "packages/spacecat-shared-google-client/node_modules/@adobe/spacecat-shared-http-utils/node_modules/@aws-sdk/client-sso-oidc": { "version": "3.716.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -33881,7 +33853,6 @@ "packages/spacecat-shared-google-client/node_modules/@adobe/spacecat-shared-http-utils/node_modules/@aws-sdk/client-sts": { "version": "3.716.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -35133,7 +35104,6 @@ "packages/spacecat-shared-google-client/node_modules/@aws-sdk/client-dynamodb": { "version": "3.721.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -37222,7 +37192,6 @@ "packages/spacecat-shared-google-client/node_modules/@aws-sdk/client-sts": { "version": "3.721.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -40115,7 +40084,6 @@ "packages/spacecat-shared-google-client/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -40613,7 +40581,6 @@ "packages/spacecat-shared-gpt-client/node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-sso-oidc": { "version": "3.721.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -40665,7 +40632,6 @@ "packages/spacecat-shared-gpt-client/node_modules/@adobe/spacecat-shared-ims-client/node_modules/@aws-sdk/client-sts": { "version": "3.721.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -42565,7 +42531,6 @@ "packages/spacecat-shared-gpt-client/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -42699,7 +42664,7 @@ }, "packages/spacecat-shared-http-utils": { "name": "@adobe/spacecat-shared-http-utils", - "version": "1.17.6", + "version": "1.17.7", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.2.3", @@ -43610,7 +43575,6 @@ "packages/spacecat-shared-http-utils/node_modules/@aws-sdk/client-dynamodb": { "version": "3.859.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -45701,7 +45665,6 @@ "packages/spacecat-shared-http-utils/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -46605,7 +46568,6 @@ "packages/spacecat-shared-ims-client/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -46718,7 +46680,7 @@ }, "packages/spacecat-shared-rum-api-client": { "name": "@adobe/spacecat-shared-rum-api-client", - "version": "2.38.1", + "version": "2.38.2", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.2.3", @@ -48422,7 +48384,6 @@ "packages/spacecat-shared-rum-api-client/node_modules/@aws-sdk/client-dynamodb": { "version": "3.859.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -50657,7 +50618,6 @@ "packages/spacecat-shared-rum-api-client/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -51598,7 +51558,6 @@ "packages/spacecat-shared-scrape-client/node_modules/@aws-sdk/client-dynamodb": { "version": "3.859.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -54642,7 +54601,6 @@ "packages/spacecat-shared-slack-client/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -56299,7 +56257,6 @@ "packages/spacecat-shared-tier-client/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -57921,7 +57878,6 @@ "packages/spacecat-shared-utils/node_modules/@aws-sdk/client-dynamodb": { "version": "3.859.0", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -59809,7 +59765,6 @@ "packages/spacecat-shared-utils/node_modules/aws-xray-sdk-core": { "version": "3.10.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", diff --git a/packages/spacecat-shared-data-access/README.md b/packages/spacecat-shared-data-access/README.md index 2a6bf8e45..0691d28f0 100644 --- a/packages/spacecat-shared-data-access/README.md +++ b/packages/spacecat-shared-data-access/README.md @@ -85,6 +85,38 @@ npm install @adobe/spacecat-shared-data-access - **status** (String): Status of the enrollment. (ACTIVE, SUSPENDED, ENDED) - **createdAt** (String): Timestamp of creation. +### FixEntity +- **fixEntityId** (String): Unique identifier for the fix entity. +- **opportunityId** (String): ID of the associated opportunity. +- **createdAt** (String): Timestamp of creation. +- **updatedAt** (String): Timestamp of the last update. +- **type** (String): Type of the fix entity (from Suggestion.TYPES). +- **status** (String): Status of the fix entity (PENDING, DEPLOYED, PUBLISHED, FAILED, ROLLED_BACK). +- **executedBy** (String): Who executed the fix. +- **executedAt** (String): When the fix was executed. +- **publishedAt** (String): When the fix was published. +- **changeDetails** (Object): Details of the changes made. + +### Suggestion +- **suggestionId** (String): Unique identifier for the suggestion. +- **opportunityId** (String): ID of the associated opportunity. +- **createdAt** (String): Timestamp of creation. +- **updatedAt** (String): Timestamp of the last update. +- **status** (String): Status of the suggestion (NEW, APPROVED, IN_PROGRESS, SKIPPED, FIXED, ERROR, OUTDATED). +- **type** (String): Type of the suggestion (CODE_CHANGE, CONTENT_UPDATE, REDIRECT_UPDATE, METADATA_UPDATE, AI_INSIGHTS, CONFIG_UPDATE). +- **rank** (Number): Rank/priority of the suggestion. +- **data** (Object): Data payload for the suggestion. +- **kpiDeltas** (Object): KPI delta information (optional). + +### FixEntitySuggestion +- **suggestionId** (String): ID of the associated suggestion (primary partition key). +- **fixEntityId** (String): ID of the associated fix entity (primary sort key). +- **opportunityId** (String): ID of the associated opportunity. +- **fixEntityCreatedAt** (String): Creation timestamp of the fix entity. +- **fixEntityCreatedDate** (String): Date portion of fixEntityCreatedAt (auto-generated). +- **createdAt** (String): Timestamp of creation. +- **updatedAt** (String): Timestamp of the last update. + ## DynamoDB Data Model The module is designed to work with the following DynamoDB tables: @@ -144,6 +176,18 @@ The module provides the following DAOs: - `getTopPagesForSite` - `addSiteTopPage` +### FixEntity Functions +- `getSuggestionsByFixEntityId` - Gets all suggestions associated with a specific FixEntity +- `setSuggestionsForFixEntity` - Sets suggestions for a FixEntity by managing junction table relationships + +### Suggestion Functions +- `bulkUpdateStatus` - Updates the status of multiple suggestions in bulk +- `getFixEntitiesBySuggestionId` - Gets all FixEntities associated with a specific Suggestion + +### FixEntitySuggestion Functions +- `allBySuggestionId` - Gets all junction records associated with a specific Suggestion +- `allByFixEntityId` - Gets all junction records associated with a specific FixEntity + ## Integrating Data Access in AWS Lambda Functions Our `spacecat-shared-data-access` module includes a wrapper that can be easily integrated into AWS Lambda functions using `@adobe/helix-shared-wrap`. diff --git a/packages/spacecat-shared-data-access/src/models/base/base.collection.js b/packages/spacecat-shared-data-access/src/models/base/base.collection.js index 2092065ec..37d9352af 100755 --- a/packages/spacecat-shared-data-access/src/models/base/base.collection.js +++ b/packages/spacecat-shared-data-access/src/models/base/base.collection.js @@ -22,7 +22,7 @@ import { ElectroValidationError } from 'electrodb'; import DataAccessError from '../../errors/data-access.error.js'; import ValidationError from '../../errors/validation.error.js'; import { createAccessors } from '../../util/accessor.utils.js'; -import { guardId } from '../../util/guards.js'; +import { guardId, guardArray } from '../../util/guards.js'; import { entityNameToAllPKValue, removeElectroProperties, @@ -361,6 +361,53 @@ class BaseCollection { return isNonEmptyObject(record?.data); } + /** + * Retrieves multiple entities by their IDs in a single batch operation. + * This method is more efficient than calling findById multiple times. + * + * @async + * @param {Array} ids - An array of entity IDs to retrieve. + * @param {{attributes?: string[]}} [options] - Additional options for the query. + * @returns {Promise<{data: Array, unprocessed: Array}>} - A promise that + * resolves + * to an object containing: + * - data: Array of found model instances + * - unprocessed: Array of IDs that couldn't be processed (due to throttling, etc.) + * @throws {DataAccessError} - Throws an error if the IDs are not provided or if the batch + * operation fails. + */ + async batchGetByKeys(keys, options = {}) { + guardArray('keys', keys, this.entityName, 'any'); + + try { + const goOptions = {}; + + // Add attributes if specified + if (options.attributes !== undefined) { + goOptions.attributes = options.attributes; + } + + const result = await this.entity.get( + keys, + ).go(goOptions); + + // Process found entities + const data = result.data + .map((record) => this.#createInstance(record)) + .filter((entity) => entity !== null); + + // Extract unprocessed keys + const unprocessed = result.unprocessed + ? result.unprocessed.map((item) => item) + : []; + + return { data, unprocessed }; + } catch (error) { + this.log.error(`Failed to batch get by keys [${this.entityName}]`, error); + throw new DataAccessError('Failed to batch get by keys', this, error); + } + } + /** * Finds a single entity by index keys. * @param {Object} keys - The index keys to use for the query. @@ -550,6 +597,51 @@ class BaseCollection { return this.#logAndThrowError('Failed to remove by IDs', error); } } + + /** + * Removes records from the collection using an array of key objects for batch deletion. + * This method is particularly useful for junction tables in many-to-many relationships + * where you need to remove multiple records based on their composite keys. + * + * Each key object in the array represents a record to be deleted, identified by its + * key attributes (typically partition key + sort key combinations). + * + * @async + * @param {Array} keys - Array of key objects to match for deletion. + * Each object should contain the key attributes that uniquely identify a record. + * @returns {Promise} A promise that resolves when the deletion is complete. + * The method also invalidates the cache after successful deletion. + * @throws {DataAccessError} Throws an error if: + * - The keys parameter is not a non-empty array + * - Any key object in the array is empty or invalid + * - The database operation fails + * + * @since 2.64.1 + * @memberof BaseCollection + */ + async removeByIndexKeys(keys) { + if (!isNonEmptyArray(keys)) { + const message = `Failed to remove by index keys [${this.entityName}]: keys must be a non-empty array`; + this.log.error(message); + throw new DataAccessError(message); + } + + keys.forEach((key) => { + if (!isNonEmptyObject(key)) { + const message = `Failed to remove by index keys [${this.entityName}]: key must be a non-empty object`; + this.log.error(message); + throw new DataAccessError(message); + } + }); + + try { + await this.entity.delete(keys).go(); + this.log.info(`Removed ${keys.length} items for [${this.entityName}]`); + return this.#invalidateCache(); + } catch (error) { + return this.#logAndThrowError('Failed to remove by index keys', error); + } + } } export default BaseCollection; diff --git a/packages/spacecat-shared-data-access/src/models/base/base.model.js b/packages/spacecat-shared-data-access/src/models/base/base.model.js index 5b9a5e7c6..12d5b5126 100755 --- a/packages/spacecat-shared-data-access/src/models/base/base.model.js +++ b/packages/spacecat-shared-data-access/src/models/base/base.model.js @@ -241,6 +241,12 @@ class BaseModel { return this._remove(); } + generateCompositeKeys() { + return { + [this.idName]: this.getId(), + }; + } + /** * Internal remove method that removes the current entity from the database and its dependents. * This method does not check if the schema allows removal in order to be able to remove @@ -269,7 +275,7 @@ class BaseModel { await Promise.all(removePromises); - await this.entity.remove({ [this.idName]: this.getId() }).go(); + await this.entity.remove(this.generateCompositeKeys()).go(); this.#invalidateCache(); diff --git a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js index c1c5e5fd4..c3b0fdceb 100755 --- a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js +++ b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js @@ -20,6 +20,7 @@ import ConfigurationCollection from '../configuration/configuration.collection.j import ExperimentCollection from '../experiment/experiment.collection.js'; import EntitlementCollection from '../entitlement/entitlement.collection.js'; import FixEntityCollection from '../fix-entity/fix-entity.collection.js'; +import FixEntitySuggestionCollection from '../fix-entity-suggestion/fix-entity-suggestion.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'; @@ -46,6 +47,7 @@ import AuditSchema from '../audit/audit.schema.js'; import ConfigurationSchema from '../configuration/configuration.schema.js'; import EntitlementSchema from '../entitlement/entitlement.schema.js'; import FixEntitySchema from '../fix-entity/fix-entity.schema.js'; +import FixEntitySuggestionSchema from '../fix-entity-suggestion/fix-entity-suggestion.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'; @@ -142,6 +144,7 @@ EntityRegistry.registerEntity(AuditSchema, AuditCollection); EntityRegistry.registerEntity(ConfigurationSchema, ConfigurationCollection); EntityRegistry.registerEntity(EntitlementSchema, EntitlementCollection); EntityRegistry.registerEntity(FixEntitySchema, FixEntityCollection); +EntityRegistry.registerEntity(FixEntitySuggestionSchema, FixEntitySuggestionCollection); EntityRegistry.registerEntity(ExperimentSchema, ExperimentCollection); EntityRegistry.registerEntity(ImportJobSchema, ImportJobCollection); EntityRegistry.registerEntity(ImportUrlSchema, ImportUrlCollection); diff --git a/packages/spacecat-shared-data-access/src/models/base/index.d.ts b/packages/spacecat-shared-data-access/src/models/base/index.d.ts index 8eb77b363..9d0835c5e 100644 --- a/packages/spacecat-shared-data-access/src/models/base/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/base/index.d.ts @@ -43,12 +43,17 @@ export interface QueryOptions { fetchAllPages?: boolean; } +export interface BatchGetOptions { + attributes?: string[]; +} + export interface BaseCollection { _onCreate(item: T): void; _onCreateMany(items: MultiStatusCreateResult): void; _saveMany(items: T[]): Promise; all(sortKeys?: object, options?: QueryOptions): Promise; allByIndexKeys(keys: object, options?: QueryOptions): Promise; + batchGetByKeys(keys: object[], options?: BatchGetOptions): Promise<{ data: T[]; unprocessed: object[] }>; create(item: object): Promise; createMany(items: object[], parent?: T): Promise>; existsById(id: string): Promise; diff --git a/packages/spacecat-shared-data-access/src/models/base/schema.js b/packages/spacecat-shared-data-access/src/models/base/schema.js index a3602036e..5ff3b0448 100644 --- a/packages/spacecat-shared-data-access/src/models/base/schema.js +++ b/packages/spacecat-shared-data-access/src/models/base/schema.js @@ -170,7 +170,9 @@ class Schema { const allKeys = [...(pk?.facets || []), ...(sk?.facets || [])]; // check if all keys in the index are in the sort keys - return subKeyNames.every((key) => allKeys.includes(key)); + const pkKeys = Array.isArray(pk?.facets) ? pk.facets : []; + return pkKeys.every((key) => subKeyNames.includes(key)) + && subKeyNames.every((key) => allKeys.includes(key)); }); if (isNonEmptyObject(index)) { diff --git a/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.collection.js b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.collection.js new file mode 100644 index 000000000..3d550126e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.collection.js @@ -0,0 +1,45 @@ +/* + * Copyright 2025 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 { guardId } from '../../util/guards.js'; +import BaseCollection from '../base/base.collection.js'; + +/** + * FixEntitySuggestionCollection - A collection class responsible for managing + * FixEntitySuggestion junction records. This collection handles the many-to-many + * relationship between FixEntity and Suggestion entities. + * + * This collection provides methods to: + * - Retrieve junction records by Suggestion ID + * - Retrieve junction records by FixEntity ID + * + * @class FixEntitySuggestionCollection + * @extends BaseCollection + */ +class FixEntitySuggestionCollection extends BaseCollection { + /** + * Gets all junction records associated with a specific Suggestion. + * + * @async + * @param {string} suggestionId - The ID of the Suggestion. + * @param {Object} options - Additional query options. + * @returns {Promise} - A promise that resolves to + * an array of FixEntitySuggestion junction records + * @throws {Error} - Throws an error if the suggestionId is not provided + */ + async allBySuggestionId(suggestionId, options = {}) { + guardId('suggestionId', suggestionId, 'FixEntitySuggestionCollection'); + return this.allByIndexKeys({ suggestionId }, options); + } +} + +export default FixEntitySuggestionCollection; diff --git a/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.model.js b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.model.js new file mode 100644 index 000000000..beb4a0f1c --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.model.js @@ -0,0 +1,38 @@ +/* + * Copyright 2025 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'; + +/** + * FixEntitySuggestion - A junction table class representing the many-to-many relationship + * between FixEntity and Suggestion entities. This allows one fix entity to be associated + * with multiple suggestions and one suggestion to be associated with multiple fix entities. + * + * @class FixEntitySuggestion + * @extends BaseModel + */ +class FixEntitySuggestion extends BaseModel { + static DEFAULT_UPDATED_BY = 'spacecat'; + + /** + * Generates the composite keys for the FixEntitySuggestion model. + * @returns {Object} - The composite keys. + */ + generateCompositeKeys() { + return { + suggestionId: this.getSuggestionId(), + fixEntityId: this.getFixEntityId(), + }; + } +} + +export default FixEntitySuggestion; diff --git a/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js new file mode 100644 index 000000000..d054589cd --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js @@ -0,0 +1,49 @@ +/* + * Copyright 2025 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 SchemaBuilder from '../base/schema.builder.js'; +import FixEntitySuggestion from './fix-entity-suggestion.model.js'; +import FixEntitySuggestionCollection from './fix-entity-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(FixEntitySuggestion, FixEntitySuggestionCollection) + .withPrimaryPartitionKeys(['suggestionId']) + .withPrimarySortKeys(['fixEntityId']) + .addReference('belongs_to', 'FixEntity') + .addReference('belongs_to', 'Suggestion') + .addAttribute('opportunityId', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('fixEntityCreatedAt', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('fixEntityCreatedDate', { + type: 'string', + readOnly: true, + watch: ['fixEntityCreatedAt'], + set: (_, { fixEntityCreatedAt }) => (fixEntityCreatedAt ? fixEntityCreatedAt.split('T')[0] : undefined), + }) + .addIndex( + { composite: ['opportunityId'] }, + { composite: ['fixEntityCreatedDate', 'updatedAt'] }, + ); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/index.d.ts b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/index.d.ts new file mode 100644 index 000000000..e1edaa7d0 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/index.d.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2025 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, FixEntity, Suggestion } from '../index'; + +export interface FixEntitySuggestion extends BaseModel { + getFixEntity(): Promise; + getSuggestion(): Promise; + getFixEntityId(): string; + setFixEntityId(value: string): this; + getSuggestionId(): string; + setSuggestionId(value: string): this; + getFixEntityCreatedAt(): string; + setFixEntityCreatedAt(value: string): this; + getFixEntityCreatedDate(): string; + setFixEntityCreatedDate(value: string): this; +} + +export interface FixEntitySuggestionCollection extends BaseCollection { + allBySuggestionId(suggestionId: string): Promise; + allByFixEntityId(fixEntityId: string): Promise; + allByOpportunityIdAndFixEntityCreatedDate(opportunityId: string, fixEntityCreatedDate: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/index.js b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/index.js new file mode 100644 index 000000000..e6806f642 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2025 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 FixEntitySuggestion from './fix-entity-suggestion.model.js'; +import FixEntitySuggestionCollection from './fix-entity-suggestion.collection.js'; + +export { + FixEntitySuggestion, + FixEntitySuggestionCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.collection.js b/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.collection.js index ac68c585e..e4df490bd 100644 --- a/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.collection.js +++ b/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.collection.js @@ -10,17 +10,235 @@ * governing permissions and limitations under the License. */ import BaseCollection from '../base/base.collection.js'; +import DataAccessError from '../../errors/data-access.error.js'; +import ValidationError from '../../errors/validation.error.js'; +import { guardId, guardArray, guardString } from '../../util/guards.js'; +import { resolveUpdates } from '../../util/util.js'; /** - * SiteCandidateCollection - A collection class responsible for managing FixEntities. + * FixEntityCollection - A collection class responsible for managing FixEntities. * Extends the BaseCollection to provide specific methods for interacting with - * FixEntity records. + * FixEntity records and their relationships with Suggestions. + * + * This collection provides methods to: + * - Retrieve suggestions associated with a specific FixEntity + * - Set suggestions for a FixEntity by managing junction table relationships * * @class FixEntityCollection * @extends BaseCollection */ class FixEntityCollection extends BaseCollection { - // add custom methods here + /** + * Gets all suggestions associated with a specific FixEntity. + * + * @async + * @param {string} fixEntityId - The ID of the FixEntity. + * @returns {Promise} - A promise that resolves to an array of Suggestion models + * @throws {DataAccessError} - Throws an error if the fixEntityId is not provided or if the + * query fails. + */ + async getSuggestionsByFixEntityId(fixEntityId) { + guardId('fixEntityId', fixEntityId, 'FixEntityCollection'); + + try { + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); + + const fixEntitySuggestions = await fixEntitySuggestionCollection + .allByFixEntityId(fixEntityId); + + if (fixEntitySuggestions.length === 0) { + return []; + } + + const suggestionCollection = this.entityRegistry.getCollection('SuggestionCollection'); + const suggestions = await suggestionCollection + .batchGetByKeys(fixEntitySuggestions + .map((record) => ({ [suggestionCollection.idName]: record.getSuggestionId() }))); + return suggestions.data; + } catch (error) { + this.log.error(`Failed to get suggestions for fix entity: ${fixEntityId}`, error); + throw new DataAccessError('Failed to get suggestions for fix entity', this, error); + } + } + + /** + * Sets suggestions for a specific FixEntity by replacing all existing suggestions with new ones. + * This method efficiently only removes relationships that are no longer needed and only adds + * new ones. + * + * @async + * @param {string} opportunityId - The ID of the opportunity. + * @param {FixEntity} fixEntity - The FixEntity entity. + * @param {Array} suggestions - An array of Suggestion entities. + * @returns {Promise<{createdItems: Array, errorItems: Array, removedCount: number}>} - A promise + * that resolves to an object containing: + * - createdItems: Array of created FixEntitySuggestionCollection junction records + * - errorItems: Array of items that failed validation + * - removedCount: Number of existing relationships that were removed + * @throws {DataAccessError} - Throws an error if the entities are not provided or if the + * operation fails. + */ + async setSuggestionsForFixEntity(opportunityId, fixEntity, suggestions) { + guardId('opportunityId', opportunityId, 'FixEntityCollection'); + guardArray('suggestions', suggestions, 'FixEntityCollection', 'any'); + + // Simple null checks + if (!fixEntity) { + throw new ValidationError('fixEntity is required'); + } + + // Extract IDs and other values from entities + const fixEntityId = fixEntity.getId(); + const fixEntityCreatedAt = fixEntity.getCreatedAt(); + const suggestionIds = suggestions.map((suggestion) => suggestion.getId()); + + try { + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); + + const existingRelationships = await fixEntitySuggestionCollection + .allByFixEntityId(fixEntityId); + + // Extract existing suggestion IDs from relationship objects + const existingSuggestionIds = existingRelationships.map((rel) => rel.getSuggestionId()); + + const { toDelete, toCreate } = resolveUpdates(existingSuggestionIds, suggestionIds); + + let removePromise; + let createPromise; + + if (toDelete.length > 0) { + removePromise = fixEntitySuggestionCollection.removeByIndexKeys( + toDelete.map((suggestionId) => ( + { + suggestionId, + fixEntityId, + })), + ); + } + + if (toCreate.length > 0) { + createPromise = fixEntitySuggestionCollection.createMany(toCreate.map((suggestionId) => ( + { + opportunityId, + fixEntityCreatedAt, + fixEntityId, + suggestionId, + }))); + } + + const [removeResult, createResult] = await Promise.allSettled([removePromise, createPromise]); + + let removedCount = 0; + let createdItems = []; + let errorItems = []; + if (removeResult.status === 'fulfilled') { + removedCount = toDelete.length; + } else { + this.log.error('Remove operation failed:', removeResult.reason); + } + + if (createResult.status === 'fulfilled') { + createdItems = createResult.value?.createdItems || []; + errorItems = createResult.value?.errorItems || []; + } else { + this.log.error('Create operation failed:', createResult.reason); + } + + this.log.info(`Set suggestions for fix entity ${fixEntityId}: removed ${removedCount}, ` + + `added ${createdItems.length}, failed ${errorItems.length}`); + + return { createdItems, errorItems, removedCount }; + } catch (error) { + this.log.error('Failed to set suggestions for fix entity', error); + throw new DataAccessError('Failed to set suggestions for fix entity', this, error); + } + } + + /** + * Gets all fixes with their suggestions for a specific opportunity and created date. + * This method retrieves all fix entities and their associated suggestions for a given opportunity + * and creation date. + * + * @async + * @param {string} opportunityId - The ID of the opportunity. + * @param {string} fixEntityCreatedDate - The creation date to filter by (YYYY-MM-DD format). + * @returns {Promise} - A promise that resolves to an array of objects containing: + * - fixEntity: The FixEntity model + * - suggestions: Array of associated Suggestion models + * @throws {DataAccessError} - Throws an error if the query fails. + * @throws {ValidationError} - Throws an error if opportunityId or + * fixEntityCreatedDate is not provided. + */ + async getAllFixesWithSuggestionByCreatedAt(opportunityId, fixEntityCreatedDate) { + guardId('opportunityId', opportunityId, 'FixEntityCollection'); + guardString('fixEntityCreatedDate', fixEntityCreatedDate, 'FixEntityCollection'); + + try { + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); + const suggestionCollection = this.entityRegistry.getCollection('SuggestionCollection'); + + // Query fix entity suggestions by opportunity ID and created date + const fixEntitySuggestions = await fixEntitySuggestionCollection + .allByOpportunityIdAndFixEntityCreatedDate(opportunityId, fixEntityCreatedDate); + + if (fixEntitySuggestions.length === 0) { + return []; + } + + // Group suggestions by fix entity ID + const suggestionsByFixEntityId = {}; + const fixEntityIds = new Set(); + + for (const fixEntitySuggestion of fixEntitySuggestions) { + const fixEntityId = fixEntitySuggestion.getFixEntityId(); + const suggestionId = fixEntitySuggestion.getSuggestionId(); + + fixEntityIds.add(fixEntityId); + + if (!suggestionsByFixEntityId[fixEntityId]) { + suggestionsByFixEntityId[fixEntityId] = []; + } + suggestionsByFixEntityId[fixEntityId].push(suggestionId); + } + + // Get all fix entities + const fixEntities = await this.batchGetByKeys( + Array.from(fixEntityIds).map((id) => ({ [this.idName]: id })), + ); + + // Get all suggestions + const allSuggestionIds = Object.values(suggestionsByFixEntityId).flat(); + const suggestions = await suggestionCollection.batchGetByKeys( + allSuggestionIds.map((id) => ({ [suggestionCollection.idName]: id })), + ); + + // Create a map of suggestions by ID for quick lookup + const suggestionsById = {}; + for (const suggestion of suggestions.data) { + suggestionsById[suggestion.getId()] = suggestion; + } + + // Combine fix entities with their suggestions + const result = []; + for (const fixEntity of fixEntities.data) { + const fixEntityId = fixEntity.getId(); + const suggestionIds = suggestionsByFixEntityId[fixEntityId] || []; + const suggestionsForFixEntity = suggestionIds + .map((id) => suggestionsById[id]) + .filter(Boolean); + + result.push({ + fixEntity, + suggestions: suggestionsForFixEntity, + }); + } + + return result; + } catch (error) { + this.log.error('Failed to get all fixes with suggestions by created date', error); + throw new DataAccessError('Failed to get all fixes with suggestions by created date', this, error); + } + } } export default FixEntityCollection; diff --git a/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.model.js b/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.model.js index b21e18214..a9c36c7da 100644 --- a/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.model.js +++ b/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.model.js @@ -28,6 +28,12 @@ class FixEntity extends BaseModel { FAILED: 'FAILED', // failed to apply the fix ROLLED_BACK: 'ROLLED_BACK', // the fix has been rolled_back }; + + async getSuggestions() { + const fixEntityCollection = this.entityRegistry.getCollection('FixEntityCollection'); + return fixEntityCollection + .getSuggestionsByFixEntityId(this.getId()); + } } export default FixEntity; diff --git a/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.schema.js b/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.schema.js index 1d66d1bcd..d5014a112 100644 --- a/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.schema.js +++ b/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.schema.js @@ -15,7 +15,7 @@ import { isIsoDate, isNonEmptyObject } from '@adobe/spacecat-shared-utils'; import SchemaBuilder from '../base/schema.builder.js'; import FixEntity from './fix-entity.model.js'; import FixEntityCollection from './fix-entity.collection.js'; -import { Suggestion } from '../suggestion/index.js'; +import Suggestion from '../suggestion/suggestion.model.js'; /* Schema Doc: https://electrodb.dev/en/modeling/schema/ @@ -24,7 +24,7 @@ Indexes Doc: https://electrodb.dev/en/modeling/indexes/ */ const schema = new SchemaBuilder(FixEntity, FixEntityCollection) - .addReference('has_many', 'Suggestion', ['status']) + .addReference('has_many', 'FixEntitySuggestion', ['updatedAt'], { removeDependents: true }) .addReference('belongs_to', 'Opportunity', ['status']) .addAttribute('type', { type: Object.values(Suggestion.TYPES), diff --git a/packages/spacecat-shared-data-access/src/models/fix-entity/index.d.ts b/packages/spacecat-shared-data-access/src/models/fix-entity/index.d.ts index 2b3752259..986825535 100644 --- a/packages/spacecat-shared-data-access/src/models/fix-entity/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/fix-entity/index.d.ts @@ -11,7 +11,7 @@ */ import type { - BaseCollection, BaseModel, Opportunity, Suggestion, + BaseCollection, BaseModel, Opportunity, Suggestion, FixEntitySuggestion, } from '../index'; export interface FixEntity extends BaseModel { @@ -28,8 +28,6 @@ export interface FixEntity extends BaseModel { setPublishedAt(value: string): this; getStatus(): string; setStatus(value: string): this; - getSuggestions(): Promise; - getSuggestionsByUpdatedAt(updatedAt: string): Promise; getType(): string; } @@ -38,4 +36,6 @@ export interface FixEntityCollection extends BaseCollection { allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; findByOpportunityId(opportunityId: string): Promise; findByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; + getSuggestionsByFixEntityId(fixEntityId: string): Promise<{data: Array, unprocessed: Array}>; + setSuggestionsForFixEntity(opportunityId: string, fixEntity: FixEntity, suggestions: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; } diff --git a/packages/spacecat-shared-data-access/src/models/index.d.ts b/packages/spacecat-shared-data-access/src/models/index.d.ts index e606e9428..21cf248e3 100755 --- a/packages/spacecat-shared-data-access/src/models/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/index.d.ts @@ -15,6 +15,7 @@ export type * from './async-job'; export type * from './configuration'; export type * from './base'; export type * from './fix-entity'; +export type * from './fix-entity-suggestion'; export type * from './experiment'; export type * from './entitlement'; export type * from './import-job'; diff --git a/packages/spacecat-shared-data-access/src/models/index.js b/packages/spacecat-shared-data-access/src/models/index.js index 90ec5129d..43fb11612 100755 --- a/packages/spacecat-shared-data-access/src/models/index.js +++ b/packages/spacecat-shared-data-access/src/models/index.js @@ -17,6 +17,7 @@ export * from './base/index.js'; export * from './configuration/index.js'; export * from './entitlement/index.js'; export * from './fix-entity/index.js'; +export * from './fix-entity-suggestion/index.js'; export * from './experiment/index.js'; export * from './import-job/index.js'; export * from './import-url/index.js'; diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js index ea5655cd7..fc466cdde 100755 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js @@ -59,22 +59,151 @@ class Opportunity extends BaseModel { /** * Adds the given fixEntities to this Opportunity. Sets this opportunity as the parent * of each fixEntity, as such the opportunity ID does not need to be provided. + * Each fixEntity must contain a suggestions array that will be used to create + * FixEntitySuggestion records. * * @async * @param {Array} fixEntities - An array of fixEntities objects to add. + * Each fixEntity must have a suggestions property with at least one suggestion. * @return {Promise<{ createdItems: BaseModel[], * errorItems: { item: Object, error: ValidationError }[] }>} - A promise that * resolves to an object containing the created fixEntities items and any * errors that occurred. */ async addFixEntities(fixEntities) { - const childFixEntities = fixEntities.map((fixEntity) => ({ - ...fixEntity, - [this.idName]: this.getId(), - })); - return this.entityRegistry - .getCollection('FixEntityCollection') - .createMany(childFixEntities, this); + const errorItems = []; + const opportunityId = this.getId(); + + // Step 1: Input validation - categorize fixEntities into valid and invalid + const validFixEntities = []; + fixEntities.forEach((fixEntity) => { + if (!fixEntity.suggestions) { + errorItems.push({ + item: fixEntity, + error: new Error('fixEntity must have a suggestions property'), + }); + } else if (!Array.isArray(fixEntity.suggestions)) { + errorItems.push({ + item: fixEntity, + error: new Error('fixEntity.suggestions must be an array'), + }); + } else if (fixEntity.suggestions.length === 0) { + errorItems.push({ + item: fixEntity, + error: new Error('fixEntity.suggestions cannot be empty'), + }); + } else { + validFixEntities.push(fixEntity); + } + }); + + // If no valid fixEntities, return early + if (validFixEntities.length === 0) { + return { createdItems: [], errorItems }; + } + + const fixEntityCollection = this.entityRegistry.getCollection('FixEntityCollection'); + const suggestionCollection = this.entityRegistry.getCollection('SuggestionCollection'); + const fixEntitySuggestionCollection = this.entityRegistry + .getCollection('FixEntitySuggestionCollection'); + + // Step 2: Flatten and fetch all unique suggestion IDs + const allSuggestionIds = new Set(); + validFixEntities.forEach((fixEntity) => { + fixEntity.suggestions.forEach((suggestionId) => { + allSuggestionIds.add(suggestionId); + }); + }); + + const suggestionResults = await suggestionCollection.batchGetByKeys( + Array.from(allSuggestionIds).map((suggestionId) => ({ + [suggestionCollection.idName]: suggestionId, + })), + ); + + // Create a map of suggestionId -> suggestion entity for O(1) retrieval + const suggestionMap = new Map(); + suggestionResults.data.forEach((suggestion) => { + suggestionMap.set(suggestion.getId(), suggestion); + }); + + // Step 3: Validate that all suggestion IDs exist and prepare fixEntities to create + const fixEntitiesToCreate = []; + validFixEntities.forEach((fixEntity) => { + const missingSuggestions = fixEntity.suggestions.filter( + (suggestionId) => !suggestionMap.has(suggestionId), + ); + + if (missingSuggestions.length > 0) { + errorItems.push({ + item: fixEntity, + error: new Error(`Invalid suggestion IDs: ${missingSuggestions.join(', ')}`), + }); + } else { + fixEntitiesToCreate.push(fixEntity); + } + }); + + // If no valid fixEntities to create, return early + if (fixEntitiesToCreate.length === 0) { + return { createdItems: [], errorItems }; + } + + // Step 4: Create FixEntity records + const fixEntityCreateResult = await fixEntityCollection.createMany( + fixEntitiesToCreate.map((fixEntity) => { + const { suggestions: _, ...fixEntityWithoutSuggestions } = fixEntity; + return { + ...fixEntityWithoutSuggestions, + [this.idName]: opportunityId, + }; + }), + this, + ); + + // Add any errors from fix entity creation + if (fixEntityCreateResult.errorItems && fixEntityCreateResult.errorItems.length > 0) { + // Match error items back to original fixEntities with suggestions + fixEntityCreateResult.errorItems.forEach((errorItem) => { + const originalIndex = fixEntitiesToCreate.findIndex( + (fe) => fe.type === errorItem.item.type + && JSON.stringify(fe.changeDetails) === JSON.stringify(errorItem.item.changeDetails), + ); + if (originalIndex !== -1) { + errorItems.push({ + item: fixEntitiesToCreate[originalIndex], + error: errorItem.error, + }); + } + }); + } + + // Step 5: Create FixEntitySuggestion junction records + const junctionRecordsToCreate = []; + fixEntityCreateResult.createdItems.forEach((createdFixEntity, index) => { + const originalFixEntity = fixEntitiesToCreate[index]; + const fixEntityId = createdFixEntity.getId(); + const fixEntityCreatedAt = createdFixEntity.getCreatedAt(); + + originalFixEntity.suggestions.forEach((suggestionId) => { + junctionRecordsToCreate.push({ + opportunityId, + fixEntityId, + suggestionId, + fixEntityCreatedAt, + }); + }); + }); + + // Create all junction records at once + if (junctionRecordsToCreate.length > 0) { + await fixEntitySuggestionCollection.createMany(junctionRecordsToCreate); + } + + return { + createdItems: fixEntityCreateResult.createdItems, + errorItems, + }; } } diff --git a/packages/spacecat-shared-data-access/src/models/suggestion/index.d.ts b/packages/spacecat-shared-data-access/src/models/suggestion/index.d.ts index 49b6619e3..23216194a 100644 --- a/packages/spacecat-shared-data-access/src/models/suggestion/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/suggestion/index.d.ts @@ -10,15 +10,13 @@ * governing permissions and limitations under the License. */ -import type { BaseCollection, BaseModel, Opportunity } from '../index'; +import type { BaseCollection, BaseModel, Opportunity, FixEntitySuggestion, FixEntity } from '../index'; export interface Suggestion extends BaseModel { getData(): object; getKpiDeltas(): object; getOpportunity(): Promise; getOpportunityId(): string; - getFixEntityId(): string; - getFixEntity(): Promise; getRank(): number; getStatus(): string; getType(): string; @@ -31,9 +29,9 @@ export interface Suggestion extends BaseModel { export interface SuggestionCollection extends BaseCollection { allByOpportunityId(opportunityId: string): Promise; - allByFixEntityId(fixEntityId: string): Promise; allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise; findByOpportunityId(opportunityId: string): Promise; findByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; + getFixEntitiesBySuggestionId(suggestionId: string): Promise<{data: Array, unprocessed: Array}>; } diff --git a/packages/spacecat-shared-data-access/src/models/suggestion/suggestion.collection.js b/packages/spacecat-shared-data-access/src/models/suggestion/suggestion.collection.js index f4b519617..b66bfd009 100644 --- a/packages/spacecat-shared-data-access/src/models/suggestion/suggestion.collection.js +++ b/packages/spacecat-shared-data-access/src/models/suggestion/suggestion.collection.js @@ -11,11 +11,18 @@ */ import BaseCollection from '../base/base.collection.js'; +import DataAccessError from '../../errors/data-access.error.js'; import Suggestion from './suggestion.model.js'; +import { guardId } from '../../util/guards.js'; /** * SuggestionCollection - A collection class responsible for managing Suggestion entities. - * Extends the BaseCollection to provide specific methods for interacting with Suggestion records. + * Extends the BaseCollection to provide specific methods for interacting with Suggestion records + * and their relationships with FixEntities. + * + * This collection provides methods to: + * - Update the status of multiple suggestions in bulk + * - Retrieve FixEntities associated with a specific Suggestion * * @class SuggestionCollection * @extends BaseCollection @@ -50,6 +57,40 @@ class SuggestionCollection extends BaseCollection { return suggestions; } + + /** + * Gets all FixEntities associated with a specific Suggestion. + * + * @async + * @param {string} suggestionId - The ID of the Suggestion. + * @returns {Promise} - A promise that resolves to an array of FixEntity models + * @throws {DataAccessError} - Throws an error if the suggestionId is not provided or if the + * query fails. + */ + async getFixEntitiesBySuggestionId(suggestionId) { + guardId('suggestionId', suggestionId, 'SuggestionCollection'); + + try { + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); + const fixEntityCollection = this.entityRegistry.getCollection('FixEntityCollection'); + + // Get all junction records for this suggestion + const fixEntitySuggestions = await fixEntitySuggestionCollection + .allBySuggestionId(suggestionId); + + if (fixEntitySuggestions.length === 0) { + return []; + } + + const fixEntityIds = fixEntitySuggestions.map((record) => record.getFixEntityId()); + const result = await fixEntityCollection + .batchGetByKeys(fixEntityIds.map((id) => ({ [fixEntityCollection.idName]: id }))); + return result.data; + } catch (error) { + this.log.error('Failed to get fix entities for suggestion', error); + throw new DataAccessError('Failed to get fix entities for suggestion', this, error); + } + } } export default SuggestionCollection; diff --git a/packages/spacecat-shared-data-access/src/models/suggestion/suggestion.schema.js b/packages/spacecat-shared-data-access/src/models/suggestion/suggestion.schema.js index 65fd641ef..4553c80d2 100644 --- a/packages/spacecat-shared-data-access/src/models/suggestion/suggestion.schema.js +++ b/packages/spacecat-shared-data-access/src/models/suggestion/suggestion.schema.js @@ -26,7 +26,7 @@ Indexes Doc: https://electrodb.dev/en/modeling/indexes/ const schema = new SchemaBuilder(Suggestion, SuggestionCollection) .addReference('belongs_to', 'Opportunity', ['status', 'rank']) - .addReference('belongs_to', 'FixEntity', ['updatedAt'], { required: false }) + .addReference('has_many', 'FixEntitySuggestion', ['updatedAt'], { removeDependents: true }) .addAttribute('type', { type: Object.values(Suggestion.TYPES), required: true, diff --git a/packages/spacecat-shared-data-access/src/util/util.js b/packages/spacecat-shared-data-access/src/util/util.js index 9319be46a..2be5a8d0b 100644 --- a/packages/spacecat-shared-data-access/src/util/util.js +++ b/packages/spacecat-shared-data-access/src/util/util.js @@ -12,6 +12,7 @@ import { hasText, isInteger } from '@adobe/spacecat-shared-utils'; import pluralize from 'pluralize'; +import { guardArray } from './guards.js'; const capitalize = (str) => (hasText(str) ? str[0].toUpperCase() + str.slice(1) : ''); @@ -94,6 +95,19 @@ const zeroPad = (num, length) => { : '0'.repeat(length - str.length) + str; }; +const resolveUpdates = (existingItems, newItems) => { + guardArray('existingItems', existingItems, 'resolveUpdates'); + guardArray('newItems', newItems, 'resolveUpdates'); + + // Deduplicate new items + const dedupedNew = [...new Set(newItems)]; + + const toDelete = existingItems.filter((item) => !dedupedNew.includes(item)); + const toCreate = dedupedNew.filter((item) => !existingItems.includes(item)); + + return { toDelete, toCreate }; +}; + export { capitalize, classExtends, @@ -113,4 +127,5 @@ export { sanitizeIdAndAuditFields, sanitizeTimestamps, zeroPad, + resolveUpdates, }; diff --git a/packages/spacecat-shared-data-access/test/fixtures/fix-entity-suggestions.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/fix-entity-suggestions.fixture.js new file mode 100644 index 000000000..c71cd6762 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/fix-entity-suggestions.fixture.js @@ -0,0 +1,17 @@ +/* + * Copyright 2025 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. + */ + +// Empty fixture for FixEntitySuggestion junction records +// The relationships will be created dynamically in the tests +const fixEntitySuggestions = []; + +export default fixEntitySuggestions; diff --git a/packages/spacecat-shared-data-access/test/fixtures/fix-entity.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/fix-entity.fixture.js index 5392b41f4..0301d2d93 100644 --- a/packages/spacecat-shared-data-access/test/fixtures/fix-entity.fixture.js +++ b/packages/spacecat-shared-data-access/test/fixtures/fix-entity.fixture.js @@ -55,6 +55,54 @@ const fixEntities = [ executedAt: '2025-02-09T23:21:55.834Z', publishedAt: '2025-03-09T23:21:55.834Z', }, + { + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + status: 'ROLLED_BACK', + type: 'METADATA_UPDATE', + changeDetails: { + description: 'Updates content for the details page', + changes: [ + { + field: 'description', oldValue: 'Hello World!', newValue: 'Welcome!', page: 'details', + }, + ], + }, + executedBy: 'developer789', + executedAt: '2025-02-09T23:21:55.834Z', + publishedAt: '2025-03-09T23:21:55.834Z', + }, + { + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + status: 'FAILED', + type: 'METADATA_UPDATE', + changeDetails: { + description: 'Updates content for the listing page', + changes: [ + { + field: 'description', oldValue: 'Hello World!', newValue: 'Welcome!', page: 'listing', + }, + ], + }, + executedBy: 'developer789', + executedAt: '2025-02-09T23:21:55.834Z', + publishedAt: '2025-03-09T23:21:55.834Z', + }, + { + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + status: 'FAILED', + type: 'METADATA_UPDATE', + changeDetails: { + description: 'Updates content for the reports page', + changes: [ + { + field: 'description', oldValue: 'Hello World!', newValue: 'Welcome!', page: 'report', + }, + ], + }, + executedBy: 'developer789', + executedAt: '2025-02-09T23:21:55.834Z', + publishedAt: '2025-03-09T23:21:55.834Z', + }, ]; export default fixEntities; diff --git a/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js index cbe18fa5a..25323697d 100644 --- a/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js +++ b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js @@ -29,6 +29,7 @@ import siteTopPages from './site-top-pages.fixture.js'; import sites from './sites.fixture.js'; import suggestions from './suggestions.fixture.js'; import fixEntities from './fix-entity.fixture.js'; +import fixEntitySuggestions from './fix-entity-suggestions.fixture.js'; import pageIntents from './page-intents.fixture.js'; import reports from './reports.fixture.js'; import entitlements from './entitlements.fixture.js'; @@ -43,6 +44,7 @@ export default { configurations, experiments, fixEntities, + fixEntitySuggestions, importJobs, importUrls, keyEvents, diff --git a/packages/spacecat-shared-data-access/test/it/fix-entity-suggestion/fix-entity-suggestion.test.js b/packages/spacecat-shared-data-access/test/it/fix-entity-suggestion/fix-entity-suggestion.test.js new file mode 100644 index 000000000..b491ca873 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/fix-entity-suggestion/fix-entity-suggestion.test.js @@ -0,0 +1,931 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { + let sampleData; + let FixEntity; + let Suggestion; + let FixEntitySuggestion; + let mockLogger; + + beforeEach(async function () { + this.timeout(10000); + sampleData = await seedDatabase(); + mockLogger = { + debug: sinon.stub(), + error: sinon.stub(), + info: sinon.stub(), + warn: sinon.stub(), + }; + + const dataAccess = getDataAccess({}, mockLogger); + FixEntity = dataAccess.FixEntity; + Suggestion = dataAccess.Suggestion; + FixEntitySuggestion = dataAccess.FixEntitySuggestion; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('sets suggestions for a fix entity using suggestion IDs', async () => { + const fixEntity = sampleData.fixEntities[0]; + const suggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], + sampleData.suggestions[2], + ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + suggestions, + ); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(3); + expect(result.errorItems).to.be.an('array').with.length(0); + expect(result.removedCount).to.equal(0); + + // Verify the relationships were created + result.createdItems.forEach((item, index) => { + expect(item.getFixEntityId()).to.equal(fixEntity.getId()); + expect(item.getSuggestionId()).to.equal(suggestions[index].getId()); + }); + }); + + it('sets suggestions for a fix entity using suggestion objects', async () => { + const fixEntity = sampleData.fixEntities[1]; + const suggestions = [ + sampleData.suggestions[3], + sampleData.suggestions[4], + ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + suggestions, + ); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(2); + expect(result.errorItems).to.be.an('array').with.length(0); + expect(result.removedCount).to.equal(0); + + // Verify the relationships were created + result.createdItems.forEach((item, index) => { + expect(item.getFixEntityId()).to.equal(fixEntity.getId()); + expect(item.getSuggestionId()).to.equal(suggestions[index].getId()); + }); + }); + + it('updates suggestions for a fix entity (removes old, adds new)', async () => { + const fixEntity = sampleData.fixEntities[0]; + const initialSuggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], + ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + // First, set initial suggestions + await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + initialSuggestions, + ); + + // Then update with different suggestions + const newSuggestions = [ + sampleData.suggestions[1], // Keep this one + sampleData.suggestions[2], // Add this one + sampleData.suggestions[3], // Add this one + ]; + + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + newSuggestions, + ); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(2); // Added 2 new + expect(result.errorItems).to.be.an('array').with.length(0); + expect(result.removedCount).to.equal(1); // Removed 1 old + + // Verify final state + const finalSuggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); + expect(finalSuggestions).to.be.an('array').with.length(3); + + const finalSuggestionIds = finalSuggestions.map((s) => s.getId()).sort(); + const newSuggestionIds = newSuggestions.map((s) => s.getId()).sort(); + expect(finalSuggestionIds).to.deep.equal(newSuggestionIds); + }); + + it('sets empty array to remove all suggestions from a fix entity', async () => { + const fixEntity = sampleData.fixEntities[1]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + // First add some suggestions + await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + [sampleData.suggestions[0], sampleData.suggestions[1]], + ); + + // Then remove all by setting empty array + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + [], + ); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(0); + expect(result.errorItems).to.be.an('array').with.length(0); + expect(result.removedCount).to.equal(2); + + // Verify no suggestions remain + const finalSuggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); + expect(finalSuggestions).to.be.an('array').with.length(0); + }); + + it('throws error when opportunityId is not provided', async () => { + const fixEntity = sampleData.fixEntities[0]; + await expect( + FixEntity.setSuggestionsForFixEntity(null, fixEntity, []), + ).to.be.rejectedWith('Validation failed in FixEntityCollection: opportunityId must be a valid UUID'); + }); + + it('gets all suggestions for a fix entity', async () => { + const fixEntity = sampleData.fixEntities[0]; + const suggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], + ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + // First set up the relationships + await FixEntity.setSuggestionsForFixEntity(opportunity.getId(), fixEntity, suggestions); + + // Then retrieve them + const result = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); + + expect(result).to.be.an('array').with.length(2); + + // Verify the suggestions are correct + const retrievedIds = result.map((s) => s.getId()).sort(); + const suggestionIds = suggestions.map((s) => s.getId()).sort(); + expect(retrievedIds).to.deep.equal(suggestionIds); + + // Verify they are proper suggestion objects + result.forEach((suggestion) => { + expect(suggestion).to.be.an('object'); + expect(suggestion.getId()).to.be.a('string'); + expect(suggestion.getOpportunityId()).to.be.a('string'); + expect(suggestion.getType()).to.be.a('string'); + expect(suggestion.getStatus()).to.be.a('string'); + }); + }); + + it('returns empty array when fix entity has no suggestions', async () => { + const fixEntity = sampleData.fixEntities[2]; + + const result = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); + + expect(result).to.be.an('array').with.length(0); + }); + + it('throws error when fixEntityId is not provided', async () => { + await expect( + FixEntity.getSuggestionsByFixEntityId(null), + ).to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntityId must be a valid UUID'); + }); + + it('gets all fix entities for a suggestion', async () => { + const suggestion = sampleData.suggestions[0]; + const fixEntityIds = [ + sampleData.fixEntities[0].getId(), + sampleData.fixEntities[1].getId(), + ]; + + // First set up the relationships using direct junction records + const junctionData = fixEntityIds.map((fixEntityId, index) => ({ + suggestionId: suggestion.getId(), + fixEntityId, + opportunityId: sampleData.fixEntities[index].getOpportunityId(), + fixEntityCreatedAt: sampleData.fixEntities[index].getCreatedAt(), + })); + await FixEntitySuggestion.createMany(junctionData); + + // Then retrieve them + const result = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); + + expect(result).to.be.an('array').with.length(2); + + // Verify the fix entities are correct + const retrievedIds = result.map((f) => f.getId()).sort(); + expect(retrievedIds).to.deep.equal(fixEntityIds.sort()); + + // Verify they are proper fix entity objects + result.forEach((fixEntity) => { + expect(fixEntity).to.be.an('object'); + expect(fixEntity.getId()).to.be.a('string'); + expect(fixEntity.getOpportunityId()).to.be.a('string'); + expect(fixEntity.getType()).to.be.a('string'); + expect(fixEntity.getStatus()).to.be.a('string'); + }); + }); + + it('returns empty array when suggestion has no fix entities', async () => { + const suggestion = sampleData.suggestions[8]; + + const result = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); + + expect(result).to.be.an('array').with.length(0); + }); + + it('throws error when suggestionId is not provided', async () => { + await expect( + Suggestion.getFixEntitiesBySuggestionId(null), + ).to.be.rejectedWith('Validation failed in SuggestionCollection: suggestionId must be a valid UUID'); + }); + + it('creates junction records directly', async () => { + const junctionData = [ + { + suggestionId: sampleData.suggestions[0].getId(), + fixEntityId: sampleData.fixEntities[0].getId(), + opportunityId: sampleData.fixEntities[0].getOpportunityId(), + fixEntityCreatedAt: sampleData.fixEntities[0].getCreatedAt(), + }, + { + suggestionId: sampleData.suggestions[1].getId(), + fixEntityId: sampleData.fixEntities[1].getId(), + opportunityId: sampleData.fixEntities[1].getOpportunityId(), + fixEntityCreatedAt: sampleData.fixEntities[1].getCreatedAt(), + }, + ]; + + const result = await FixEntitySuggestion.createMany(junctionData); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(2); + expect(result.errorItems).to.be.an('array').with.length(0); + + result.createdItems.forEach((item, index) => { + expect(item.getSuggestionId()).to.equal(junctionData[index].suggestionId); + expect(item.getFixEntityId()).to.equal(junctionData[index].fixEntityId); + }); + }); + + it('gets junction records by suggestion ID', async () => { + const suggestionId = sampleData.suggestions[0].getId(); + const fixEntity = sampleData.fixEntities[0]; + + // Create a junction record first + await FixEntitySuggestion.create({ + suggestionId, + fixEntityId: fixEntity.getId(), + opportunityId: fixEntity.getOpportunityId(), + fixEntityCreatedAt: fixEntity.getCreatedAt(), + }); + + const junctionRecords = await FixEntitySuggestion.allBySuggestionId(suggestionId); + + expect(junctionRecords).to.be.an('array'); + expect(junctionRecords.length).to.be.greaterThan(0); + + junctionRecords.forEach((record) => { + expect(record.getSuggestionId()).to.equal(suggestionId); + expect(record.getFixEntityId()).to.be.a('string'); + }); + }); + + it('gets junction records by fix entity ID', async () => { + const fixEntity = sampleData.fixEntities[0]; + const fixEntityId = fixEntity.getId(); + + // Create a junction record first + await FixEntitySuggestion.create({ + suggestionId: sampleData.suggestions[0].getId(), + fixEntityId, + opportunityId: fixEntity.getOpportunityId(), + fixEntityCreatedAt: fixEntity.getCreatedAt(), + }); + + const junctionRecords = await FixEntitySuggestion.allByFixEntityId(fixEntityId); + + expect(junctionRecords).to.be.an('array'); + expect(junctionRecords.length).to.be.greaterThan(0); + + junctionRecords.forEach((record) => { + expect(record.getFixEntityId()).to.equal(fixEntityId); + expect(record.getSuggestionId()).to.be.a('string'); + }); + }); + + it('handles mixed valid and invalid suggestion IDs gracefully', async () => { + const fixEntity = sampleData.fixEntities[0]; + const mixedSuggestions = [ + sampleData.suggestions[0], // Valid + { getId: () => 'invalid-suggestion-id' }, // Invalid + sampleData.suggestions[1], // Valid + ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + // This should not throw an error, but should handle validation at the junction level + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + mixedSuggestions, + ); + + // The behavior depends on validation - some items might be created, others might error + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array'); + expect(result.errorItems).to.be.an('array'); + }); + + it('handles duplicate suggestion IDs in the input array', async () => { + const fixEntity = sampleData.fixEntities[1]; + const duplicateSuggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], + sampleData.suggestions[0], // Duplicate + ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + duplicateSuggestions, + ); + + // Should only create unique relationships + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(2); + expect(result.errorItems).to.be.an('array').with.length(0); + }); + + it('handles setting the same suggestions multiple times (idempotent)', async () => { + const fixEntity = sampleData.fixEntities[2]; + const suggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], + ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + // Set suggestions first time + const result1 = await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + suggestions, + ); + + expect(result1.createdItems).to.be.an('array').with.length(2); + expect(result1.removedCount).to.equal(0); + + // Set the same suggestions again + const result2 = await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + suggestions, + ); + + expect(result2.createdItems).to.be.an('array').with.length(0); + expect(result2.removedCount).to.equal(0); + + // Verify final state + const finalSuggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); + expect(finalSuggestions).to.be.an('array').with.length(2); + }); + + it('maintains consistency when setting relationships from both sides', async () => { + const fixEntity = sampleData.fixEntities[0]; + const suggestion = sampleData.suggestions[0]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + // Set relationship from FixEntity side + await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + [suggestion], + ); + + // Verify from Suggestion side + const fixEntitiesFromSuggestion = await Suggestion.getFixEntitiesBySuggestionId( + suggestion.getId(), + ); + expect(fixEntitiesFromSuggestion).to.be.an('array').with.length(1); + expect(fixEntitiesFromSuggestion[0].getId()).to.equal(fixEntity.getId()); + + // Set additional relationship from FixEntity side (using second fix entity) + const opportunity2 = { getId: () => sampleData.fixEntities[1].getOpportunityId() }; + await FixEntity.setSuggestionsForFixEntity( + opportunity2.getId(), + sampleData.fixEntities[1], + [suggestion], + ); + + // Verify from FixEntity side + const suggestionsFromFixEntity1 = await FixEntity.getSuggestionsByFixEntityId( + fixEntity.getId(), + ); + const suggestionsFromFixEntity2 = await FixEntity.getSuggestionsByFixEntityId( + sampleData.fixEntities[1].getId(), + ); + + expect(suggestionsFromFixEntity1).to.be.an('array').with.length(1); + expect(suggestionsFromFixEntity1[0].getId()).to.equal(suggestion.getId()); + + expect(suggestionsFromFixEntity2).to.be.an('array').with.length(1); + expect(suggestionsFromFixEntity2[0].getId()).to.equal(suggestion.getId()); + }); + + it('cascades delete of junction records when fix entity is deleted', async () => { + const fixEntity = sampleData.fixEntities[0]; + const suggestion1 = sampleData.suggestions[0]; + const suggestion2 = sampleData.suggestions[1]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + // Create relationships between fix entity and suggestions + await FixEntity.setSuggestionsForFixEntity( + opportunity.getId(), + fixEntity, + [suggestion1, suggestion2], + ); + + // Verify relationships existy + const firstJunctionRecord = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion1.getId(), + fixEntityId: fixEntity.getId(), + }); + const secondJunctionRecord = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion2.getId(), + fixEntityId: fixEntity.getId(), + }); + expect(firstJunctionRecord).to.be.an('array').with.length(1); + expect(secondJunctionRecord).to.be.an('array').with.length(1); + + // Delete the fix entity (this should cascade delete junction records) + await fixEntity.remove(); + + // Verify junction records are deleted + const firstJunctionRecordAfter = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion1.getId(), + fixEntityId: fixEntity.getId(), + }); + const secondJunctionRecordAfter = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion2.getId(), + fixEntityId: fixEntity.getId(), + }); + expect(firstJunctionRecordAfter).to.be.an('array').with.length(0); + expect(secondJunctionRecordAfter).to.be.an('array').with.length(0); + + // Verify suggestions still exist (they should not be deleted) + const suggestion1After = await Suggestion.findById(suggestion1.getId()); + const suggestion2After = await Suggestion.findById(suggestion2.getId()); + expect(suggestion1After).to.not.be.null; + expect(suggestion2After).to.not.be.null; + }); + + it('cascades delete of junction records when suggestion is deleted', async () => { + const suggestion = sampleData.suggestions[2]; + const fixEntity1 = sampleData.fixEntities[1]; + const fixEntity2 = sampleData.fixEntities[2]; + + // Create relationships between suggestion and fix entities using direct junction records + const junctionData = [ + { + suggestionId: suggestion.getId(), + fixEntityId: fixEntity1.getId(), + opportunityId: fixEntity1.getOpportunityId(), + fixEntityCreatedAt: fixEntity1.getCreatedAt(), + }, + { + suggestionId: suggestion.getId(), + fixEntityId: fixEntity2.getId(), + opportunityId: fixEntity2.getOpportunityId(), + fixEntityCreatedAt: fixEntity2.getCreatedAt(), + }, + ]; + await FixEntitySuggestion.createMany(junctionData); + + // Verify relationships exist + const firstJunctionRecordBefore = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion.getId(), + fixEntityId: fixEntity1.getId(), + }); + const secondJunctionRecordBefore = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion.getId(), + fixEntityId: fixEntity2.getId(), + }); + expect(firstJunctionRecordBefore).to.be.an('array').with.length(1); + expect(secondJunctionRecordBefore).to.be.an('array').with.length(1); + + // Delete the suggestion (this should cascade delete junction records) + await suggestion.remove(); + + // Verify junction records are deleted + const firstJunctionRecordAfter = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion.getId(), + fixEntityId: fixEntity1.getId(), + }); + const secondJunctionRecordAfter = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion.getId(), + fixEntityId: fixEntity2.getId(), + }); + expect(firstJunctionRecordAfter).to.be.an('array').with.length(0); + expect(secondJunctionRecordAfter).to.be.an('array').with.length(0); + + // Verify fix entities still exist (they should not be deleted) + const fixEntity1After = await FixEntity.findById(fixEntity1.getId()); + const fixEntity2After = await FixEntity.findById(fixEntity2.getId()); + expect(fixEntity1After).to.not.be.null; + expect(fixEntity2After).to.not.be.null; + }); + + it('only deletes junction records for the deleted entity, not others', async () => { + const fixEntity1 = sampleData.fixEntities[3]; + const fixEntity2 = sampleData.fixEntities[4]; + const suggestion1 = sampleData.suggestions[3]; + const suggestion2 = sampleData.suggestions[4]; + const opportunity1 = { + getId: () => fixEntity1.getOpportunityId(), + }; + const opportunity2 = { + getId: () => fixEntity2.getOpportunityId(), + }; + + // Create multiple relationships + await FixEntity.setSuggestionsForFixEntity( + opportunity1.getId(), + fixEntity1, + [suggestion1, suggestion2], + ); + await FixEntity.setSuggestionsForFixEntity( + opportunity2.getId(), + fixEntity2, + [suggestion1], // suggestion1 is related to both fix entities + ); + + // Verify initial state + const firstJunctionRecords = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion1.getId(), + fixEntityId: fixEntity1.getId(), + }); + const secondJunctionRecords = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion1.getId(), + fixEntityId: fixEntity2.getId(), + }); + const thirdJunctionRecords = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion2.getId(), + fixEntityId: fixEntity1.getId(), + }); + expect(firstJunctionRecords).to.be.an('array').with.length(1); + expect(secondJunctionRecords).to.be.an('array').with.length(1); + expect(thirdJunctionRecords).to.be.an('array').with.length(1); + + // Delete fixEntity1 (this should only delete its junction records) + await fixEntity1.remove(); + + // Verify only fixEntity1's junction records are deleted + const firstJunctionRecordsAfter = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion1.getId(), + fixEntityId: fixEntity1.getId(), + }); + const secondJunctionRecordsAfter = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion1.getId(), + fixEntityId: fixEntity2.getId(), + }); + const thirdJunctionRecordsAfter = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion2.getId(), + fixEntityId: fixEntity1.getId(), + }); + + expect(firstJunctionRecordsAfter).to.be.an('array').with.length(0); + expect(secondJunctionRecordsAfter).to.be.an('array').with.length(1); // Should remain unchanged + expect(thirdJunctionRecordsAfter).to.be.an('array').with.length(0); // Only one relationship remains + + // Verify other entities still exist + const fixEntity2After = await FixEntity.findById(fixEntity2.getId()); + const suggestion1After = await Suggestion.findById(suggestion1.getId()); + const suggestion2After = await Suggestion.findById(suggestion2.getId()); + expect(fixEntity2After).to.not.be.null; + expect(suggestion1After).to.not.be.null; + expect(suggestion2After).to.not.be.null; + }); + + it('handles cascading delete when entity has no relationships', async () => { + const fixEntity = sampleData.fixEntities[5]; // Use an entity with no relationships + const suggestion = sampleData.suggestions[5]; // Use an entity with no relationships + + // Verify no relationships exist initially + const junctionRecordsFixEntityBefore = await FixEntitySuggestion.allByIndexKeys({ + suggestionId: suggestion.getId(), + fixEntityId: fixEntity.getId(), + }); + expect(junctionRecordsFixEntityBefore).to.be.an('array').with.length(0); + + // Delete entities (should not cause any errors) + await fixEntity.remove(); + await suggestion.remove(); + + // Verify entities are deleted + const fixEntityAfter = await FixEntity.findById(fixEntity.getId()); + const suggestionAfter = await Suggestion.findById(suggestion.getId()); + expect(fixEntityAfter).to.be.null; + expect(suggestionAfter).to.be.null; + }); + + it('gets junction records by opportunity ID and fix entity created date', async () => { + const opportunityId = 'd27f4e5a-850c-441e-9c22-8e5e08b1e687'; + const fixEntityCreatedDate = '2024-01-15'; + + // Create test data with specific opportunity ID and created date + const fixEntity1 = await FixEntity.create({ + opportunityId, + type: 'CONTENT_UPDATE', + status: 'PENDING', + changeDetails: { + description: 'Test fix entity 1', + changes: [{ field: 'title', oldValue: 'Old Title', newValue: 'New Title' }], + }, + }); + + const fixEntity2 = await FixEntity.create({ + opportunityId, + type: 'METADATA_UPDATE', + status: 'PENDING', + changeDetails: { + description: 'Test fix entity 2', + changes: [{ field: 'description', oldValue: 'Old Desc', newValue: 'New Desc' }], + }, + }); + + const fixEntity3 = await FixEntity.create({ + opportunityId: '742c49a7-d61f-4c62-9f7c-3207f520ed1e', + type: 'CODE_CHANGE', + status: 'PENDING', + changeDetails: { + description: 'Test fix entity 3', + changes: [{ field: 'code', oldValue: 'Old Code', newValue: 'New Code' }], + }, + }); + + const suggestion1 = await Suggestion.create({ + opportunityId, + title: 'Test Suggestion 1', + description: 'Description for Test Suggestion 1', + data: { foo: 'bar-1' }, + type: 'CONTENT_UPDATE', + rank: 0, + status: 'NEW', + }); + + const suggestion2 = await Suggestion.create({ + opportunityId, + title: 'Test Suggestion 2', + description: 'Description for Test Suggestion 2', + data: { foo: 'bar-2' }, + type: 'METADATA_UPDATE', + rank: 1, + status: 'NEW', + }); + + // Create junction records with specific dates + await FixEntitySuggestion.create({ + suggestionId: suggestion1.getId(), + fixEntityId: fixEntity1.getId(), + opportunityId: fixEntity1.getOpportunityId(), + fixEntityCreatedAt: '2024-01-15T10:30:00.000Z', + }); + + await FixEntitySuggestion.create({ + suggestionId: suggestion2.getId(), + fixEntityId: fixEntity2.getId(), + opportunityId: fixEntity2.getOpportunityId(), + fixEntityCreatedAt: '2024-01-15T14:45:00.000Z', + }); + + // Create a junction record with different opportunity ID (should not be returned) + await FixEntitySuggestion.create({ + suggestionId: suggestion1.getId(), + fixEntityId: fixEntity3.getId(), + opportunityId: fixEntity3.getOpportunityId(), + fixEntityCreatedAt: '2024-01-15T16:00:00.000Z', + }); + + // Test the accessor method + const result = await FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate( + opportunityId, + fixEntityCreatedDate, + ); + + expect(result).to.be.an('array').with.length(2); + + // Verify all returned records have the correct opportunity ID and date + result.forEach((record) => { + expect(record.getOpportunityId()).to.equal(opportunityId); + expect(record.getFixEntityCreatedDate()).to.equal(fixEntityCreatedDate); + expect(record.getSuggestionId()).to.be.a('string'); + expect(record.getFixEntityId()).to.be.a('string'); + }); + + // Verify we got the expected records + const returnedFixEntityIds = result.map((r) => r.getFixEntityId()).sort(); + const expectedFixEntityIds = [fixEntity1.getId(), fixEntity2.getId()].sort(); + expect(returnedFixEntityIds).to.deep.equal(expectedFixEntityIds); + }); + + it('returns empty array when no junction records match opportunity ID and date', async () => { + const opportunityId = 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4'; + const fixEntityCreatedDate = '2024-01-15'; + + const result = await FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate( + opportunityId, + fixEntityCreatedDate, + ); + + expect(result).to.be.an('array').with.length(0); + }); + + it('throws error when opportunityId is not provided', async () => { + await expect( + FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate(null, '2024-01-15'), + ).to.be.rejectedWith('opportunityId is required'); + + await expect( + FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate('', '2024-01-15'), + ).to.be.rejectedWith('opportunityId is required'); + + await expect( + FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate(undefined, '2024-01-15'), + ).to.be.rejectedWith('opportunityId is required'); + }); + + it('throws error when fixEntityCreatedDate is not provided', async () => { + const opportunityId = 'd27f4e5a-850c-441e-9c22-8e5e08b1e687'; + + await expect( + FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate(opportunityId, null), + ).to.be.rejectedWith('fixEntityCreatedDate is required'); + + await expect( + FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate(opportunityId, ''), + ).to.be.rejectedWith('fixEntityCreatedDate is required'); + + await expect( + FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate(opportunityId, undefined), + ).to.be.rejectedWith('fixEntityCreatedDate is required'); + }); + + it('handles different date formats correctly', async () => { + const opportunityId = 'd27f4e5a-850c-441e-9c22-8e5e08b1e687'; + const fixEntityCreatedDate = '2024-01-15'; + + // Create fix entity with specific date + const fixEntity = await FixEntity.create({ + opportunityId, + type: 'CONTENT_UPDATE', + status: 'PENDING', + changeDetails: { + description: 'Date test fix entity', + changes: [{ field: 'title', oldValue: 'Old Title', newValue: 'New Title' }], + }, + }); + + const suggestion = await Suggestion.create({ + opportunityId, + title: 'Date Test Suggestion', + description: 'Description for Date Test Suggestion', + data: { foo: 'bar' }, + type: 'CONTENT_UPDATE', + rank: 0, + status: 'NEW', + }); + + // Create junction record + await FixEntitySuggestion.create({ + suggestionId: suggestion.getId(), + fixEntityId: fixEntity.getId(), + opportunityId: fixEntity.getOpportunityId(), + fixEntityCreatedAt: '2024-01-15T23:59:59.999Z', + }); + + // Test that the date is correctly extracted (should be 2024-01-15) + const result = await FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate( + opportunityId, + fixEntityCreatedDate, + ); + + expect(result).to.be.an('array').with.length(1); + expect(result[0].getFixEntityCreatedDate()).to.equal('2024-01-15'); + }); + + it('supports pagination options', async () => { + const opportunityId = 'd27f4e5a-850c-441e-9c22-8e5e08b1e687'; + const fixEntityCreatedDate = '2024-01-15'; + + // Create multiple fix entities and suggestions + const fixEntities = []; + const suggestions = []; + + // Create all fix entities and suggestions in parallel + const createPromises = Array.from({ length: 5 }, async (_, i) => { + const fixEntity = await FixEntity.create({ + opportunityId, + type: 'CONTENT_UPDATE', + status: 'PENDING', + changeDetails: { + description: `Pagination test fix entity ${i}`, + changes: [{ field: 'title', oldValue: `Old Title ${i}`, newValue: `New Title ${i}` }], + }, + }); + + const suggestion = await Suggestion.create({ + opportunityId, + title: `Pagination Test Suggestion ${i}`, + description: `Description for Pagination Test Suggestion ${i}`, + data: { foo: `bar-${i}` }, + type: 'CONTENT_UPDATE', + rank: i, + status: 'NEW', + }); + + // Create junction record + await FixEntitySuggestion.create({ + suggestionId: suggestion.getId(), + fixEntityId: fixEntity.getId(), + opportunityId: fixEntity.getOpportunityId(), + fixEntityCreatedAt: '2024-01-15T10:00:00.000Z', + }); + + return { fixEntity, suggestion }; + }); + + const results = await Promise.all(createPromises); + results.forEach(({ fixEntity, suggestion }) => { + fixEntities.push(fixEntity); + suggestions.push(suggestion); + }); + + // Test with limit + const limitedResult = await FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate( + opportunityId, + fixEntityCreatedDate, + { limit: 3 }, + ); + + expect(limitedResult).to.be.an('array').with.length(3); + + // Test without limit (should return all) + const allResult = await FixEntitySuggestion.allByOpportunityIdAndFixEntityCreatedDate( + opportunityId, + fixEntityCreatedDate, + ); + + expect(allResult).to.be.an('array').with.length(5); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/fix-entity/fix-entity.test.js b/packages/spacecat-shared-data-access/test/it/fix-entity/fix-entity.test.js index f4fe5fc8a..ebcb28763 100644 --- a/packages/spacecat-shared-data-access/test/it/fix-entity/fix-entity.test.js +++ b/packages/spacecat-shared-data-access/test/it/fix-entity/fix-entity.test.js @@ -20,6 +20,14 @@ import fixEntityFixtures from '../../fixtures/fix-entity.fixture.js'; use(chaiAsPromised); +function checkSuggestion(suggestion) { + expect(suggestion).to.be.an('object'); + expect(suggestion.getId()).to.be.a('string'); + expect(suggestion.getOpportunityId()).to.be.a('string'); + expect(suggestion.getStatus()).to.be.a('string'); + expect(suggestion.getType()).to.be.a('string'); +} + function checkFixEntity(fixEntity) { expect(fixEntity).to.be.an('object'); expect(fixEntity.getId()).to.be.a('string'); @@ -31,13 +39,16 @@ function checkFixEntity(fixEntity) { describe('FixEntity IT', async () => { let FixEntity; + let Suggestion; let sampleData; - before(async () => { + before(async function () { + this.timeout(10000); sampleData = await seedDatabase(); const dataAccess = getDataAccess(); FixEntity = dataAccess.FixEntity; + Suggestion = dataAccess.Suggestion; }); it('finds one fix entity by id', async () => { @@ -123,4 +134,174 @@ describe('FixEntity IT', async () => { const notFound = await FixEntity.findById(sampleData.fixEntities[0].getId()); expect(notFound).to.equal(null); }); + + it('gets suggestions for a fix entity', async () => { + const fixEntity = sampleData.fixEntities[0]; + + // First, set up some suggestions for this fix entity + const suggestionsToSet = [ + sampleData.suggestions[0], + sampleData.suggestions[1], + ]; + + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + await FixEntity.setSuggestionsForFixEntity(opportunity.getId(), fixEntity, suggestionsToSet); + + // Test the model method + const suggestions = await fixEntity.getSuggestions(); + + expect(suggestions).to.be.an('array').with.length(2); + suggestions.forEach((suggestion) => { + checkSuggestion(suggestion); + expect(suggestionsToSet.map((s) => s.getId())).to.include(suggestion.getId()); + }); + }); + + it('gets all fixes with suggestions by created date', async () => { + // First, create some fix entities with specific created dates + const opportunityId = 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4'; + const fixEntityCreatedDate = new Date().toISOString().split('T')[0]; // Today's date in YYYY-MM-DD format + + // Create fix entities with the same opportunity and created date + const fixEntity1 = await FixEntity.create({ + opportunityId, + status: 'PENDING', + type: 'CONTENT_UPDATE', + changeDetails: { + description: 'Test fix entity 1', + changes: [{ field: 'title', oldValue: 'Old', newValue: 'New' }], + }, + }); + + const fixEntity2 = await FixEntity.create({ + opportunityId, + status: 'PENDING', + type: 'METADATA_UPDATE', + changeDetails: { + description: 'Test fix entity 2', + changes: [{ field: 'description', oldValue: 'Old', newValue: 'New' }], + }, + }); + + // Create suggestions + const suggestion1 = await Suggestion.create({ + opportunityId, + title: 'Test Suggestion 1', + description: 'Description for suggestion 1', + data: { + foo: 'bar-1', + }, + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + }); + + const suggestion2 = await Suggestion.create({ + opportunityId, + title: 'Test Suggestion 2', + description: 'Description for suggestion 2', + data: { + foo: 'bar-2', + }, + type: 'CODE_CHANGE', + rank: 1, + status: 'NEW', + }); + + const suggestion3 = await Suggestion.create({ + opportunityId, + title: 'Test Suggestion 3', + description: 'Description for suggestion 3', + data: { + foo: 'bar-3', + }, + type: 'CODE_CHANGE', + rank: 2, + status: 'NEW', + }); + + // Set up relationships between fix entities and suggestions + const opportunity = { getId: () => opportunityId }; + + // Associate suggestion1 and suggestion2 with fixEntity1 + await FixEntity + .setSuggestionsForFixEntity(opportunity.getId(), fixEntity1, [suggestion1, suggestion2]); + + // Associate suggestion3 with fixEntity2 + await FixEntity.setSuggestionsForFixEntity(opportunity.getId(), fixEntity2, [suggestion3]); + + // Test the getAllFixesWithSuggestionByCreatedAt method + const result = await FixEntity.getAllFixesWithSuggestionByCreatedAt( + opportunityId, + fixEntityCreatedDate, + ); + + expect(result).to.be.an('array'); + expect(result.length).to.equal(2); + + // Check the structure of each result + result.forEach((item) => { + expect(item).to.have.property('fixEntity'); + expect(item).to.have.property('suggestions'); + expect(item.suggestions).to.be.an('array'); + + checkFixEntity(item.fixEntity); + expect(item.fixEntity.getOpportunityId()).to.equal(opportunityId); + + item.suggestions.forEach((suggestion) => { + checkSuggestion(suggestion); + expect(suggestion.getOpportunityId()).to.equal(opportunityId); + }); + }); + + // Verify that we have the correct fix entities + const fixEntityIds = result.map((item) => item.fixEntity.getId()); + expect(fixEntityIds).to.include(fixEntity1.getId()); + expect(fixEntityIds).to.include(fixEntity2.getId()); + + // Verify that fixEntity1 has 2 suggestions and fixEntity2 has 1 suggestion + const fixEntity1Result = result.find((item) => item.fixEntity.getId() === fixEntity1.getId()); + const fixEntity2Result = result.find((item) => item.fixEntity.getId() === fixEntity2.getId()); + + expect(fixEntity1Result.suggestions).to.have.length(2); + expect(fixEntity2Result.suggestions).to.have.length(1); + + // Verify the suggestion IDs match + const fixEntity1SuggestionIds = fixEntity1Result.suggestions.map((s) => s.getId()); + expect(fixEntity1SuggestionIds).to.include(suggestion1.getId()); + expect(fixEntity1SuggestionIds).to.include(suggestion2.getId()); + + const fixEntity2SuggestionIds = fixEntity2Result.suggestions.map((s) => s.getId()); + expect(fixEntity2SuggestionIds).to.include(suggestion3.getId()); + }); + + it('returns empty array when no fixes found for given opportunity and date', async () => { + const opportunityId = '00000000-0000-0000-0000-000000000000'; + const fixEntityCreatedDate = new Date().toISOString().split('T')[0]; // Today's date in YYYY-MM-DD format + + const result = await FixEntity.getAllFixesWithSuggestionByCreatedAt( + opportunityId, + fixEntityCreatedDate, + ); + + expect(result).to.be.an('array'); + expect(result.length).to.equal(0); + }); + + it('validates required parameters', async () => { + const today = new Date().toISOString().split('T')[0]; // Today's date in YYYY-MM-DD format + + // Test missing opportunityId + await expect( + FixEntity.getAllFixesWithSuggestionByCreatedAt(null, today), + ).to.be.rejectedWith('opportunityId must be a valid UUID'); + + // Test missing fixEntityCreatedDate + await expect( + FixEntity.getAllFixesWithSuggestionByCreatedAt('aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', null), + ).to.be.rejectedWith('fixEntityCreatedDate is required'); + }); }); 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 index 3e58f0b6c..17b6a3c9b 100644 --- a/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js +++ b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js @@ -37,6 +37,8 @@ describe('Opportunity IT', async () => { let Opportunity; let Suggestion; + let FixEntity; + let FixEntitySuggestion; before(async () => { sampleData = await seedDatabase(); @@ -53,6 +55,8 @@ describe('Opportunity IT', async () => { const dataAccess = getDataAccess({}, mockLogger); Opportunity = dataAccess.Opportunity; Suggestion = dataAccess.Suggestion; + FixEntity = dataAccess.FixEntity; + FixEntitySuggestion = dataAccess.FixEntitySuggestion; }); afterEach(() => { @@ -242,6 +246,9 @@ describe('Opportunity IT', async () => { expect(stillThere).to.be.an('object'); // make sure the other suggestions are removed + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); const remainingSuggestions = await Suggestion.allByOpportunityId(opportunity.getId()); expect(remainingSuggestions).to.be.an('array').with.length(1); expect(remainingSuggestions[0].getId()).to.equal(suggestions[0].getId()); @@ -364,4 +371,244 @@ describe('Opportunity IT', async () => { expect(record1).to.eql(data[0]); expect(record2).to.eql(data[1]); }); + + describe('addFixEntities', () => { + it('creates fix entities with valid suggestions', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); + const suggestions = await opportunity.getSuggestions(); + + expect(suggestions).to.be.an('array').with.length(3); + + const fixEntityData = [ + { + type: 'CODE_CHANGE', + changeDetails: { + file: 'test1.js', + changes: 'some changes', + }, + suggestions: [suggestions[0].getId(), suggestions[1].getId()], + }, + { + type: 'CONTENT_UPDATE', + changeDetails: { + file: 'test2.md', + changes: 'content changes', + }, + suggestions: [suggestions[2].getId()], + }, + ]; + + const result = await opportunity.addFixEntities(fixEntityData); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(2); + expect(result.errorItems).to.be.an('array').with.length(0); + + // Verify fix entities were created + const fixEntity1 = result.createdItems[0]; + const fixEntity2 = result.createdItems[1]; + + expect(isValidUUID(fixEntity1.getId())).to.be.true; + expect(isValidUUID(fixEntity2.getId())).to.be.true; + expect(fixEntity1.getType()).to.equal('CODE_CHANGE'); + expect(fixEntity2.getType()).to.equal('CONTENT_UPDATE'); + expect(fixEntity1.getStatus()).to.equal('PENDING'); + expect(fixEntity2.getStatus()).to.equal('PENDING'); + + // Verify junction records were created + const junctionRecords1 = await FixEntitySuggestion.allByFixEntityId(fixEntity1.getId()); + const junctionRecords2 = await FixEntitySuggestion.allByFixEntityId(fixEntity2.getId()); + + expect(junctionRecords1).to.be.an('array').with.length(2); + expect(junctionRecords2).to.be.an('array').with.length(1); + + // Verify the fix entities can be retrieved through their suggestions + const suggestionsForFixEntity1 = await FixEntity.getSuggestionsByFixEntityId( + fixEntity1.getId(), + ); + expect(suggestionsForFixEntity1).to.be.an('array').with.length(2); + expect(suggestionsForFixEntity1.map((s) => s.getId())).to.have.members([ + suggestions[0].getId(), + suggestions[1].getId(), + ]); + }); + + it('handles invalid fixEntities without suggestions property', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); + + const fixEntityData = [ + { + type: 'CODE_CHANGE', + changeDetails: { + file: 'test.js', + }, + // Missing suggestions property + }, + ]; + + const result = await opportunity.addFixEntities(fixEntityData); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(0); + expect(result.errorItems).to.be.an('array').with.length(1); + expect(result.errorItems[0].error.message).to.equal('fixEntity must have a suggestions property'); + }); + + it('handles fixEntities with empty suggestions array', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); + + const fixEntityData = [ + { + type: 'CODE_CHANGE', + changeDetails: { + file: 'test.js', + }, + suggestions: [], + }, + ]; + + const result = await opportunity.addFixEntities(fixEntityData); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(0); + expect(result.errorItems).to.be.an('array').with.length(1); + expect(result.errorItems[0].error.message).to.equal('fixEntity.suggestions cannot be empty'); + }); + + it('handles fixEntities with invalid suggestion IDs', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); + + const fixEntityData = [ + { + type: 'CODE_CHANGE', + changeDetails: { + file: 'test.js', + }, + suggestions: ['invalid-suggestion-id', 'another-invalid-id'], + }, + ]; + + const result = await opportunity.addFixEntities(fixEntityData); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(0); + expect(result.errorItems).to.be.an('array').with.length(1); + expect(result.errorItems[0].error.message).to.include('Invalid suggestion IDs'); + }); + + it('processes mixed valid and invalid fixEntities', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); + const suggestions = await opportunity.getSuggestions(); + + const fixEntityData = [ + { + type: 'CODE_CHANGE', + changeDetails: { + file: 'valid.js', + }, + suggestions: [suggestions[0].getId()], + }, + { + type: 'CONTENT_UPDATE', + changeDetails: { + file: 'no-suggestions.md', + }, + // Missing suggestions + }, + { + type: 'REDIRECT_UPDATE', + changeDetails: { + from: '/old', + to: '/new', + }, + suggestions: [], // Empty array + }, + { + type: 'METADATA_UPDATE', + changeDetails: { + title: 'Updated Title', + }, + suggestions: ['invalid-id'], // Invalid suggestion ID + }, + { + type: 'AI_INSIGHTS', + changeDetails: { + insights: 'Some insights', + }, + suggestions: [suggestions[1].getId(), suggestions[2].getId()], + }, + ]; + + const result = await opportunity.addFixEntities(fixEntityData); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(2); + expect(result.errorItems).to.be.an('array').with.length(3); + + // Verify the valid ones were created + expect(result.createdItems[0].getType()).to.equal('CODE_CHANGE'); + expect(result.createdItems[1].getType()).to.equal('AI_INSIGHTS'); + + // Verify error messages + expect(result.errorItems[0].error.message).to.equal('fixEntity must have a suggestions property'); + expect(result.errorItems[1].error.message).to.equal('fixEntity.suggestions cannot be empty'); + expect(result.errorItems[2].error.message).to.include('Invalid suggestion IDs'); + }); + + it('handles fixEntity creation errors from validation', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); + const suggestions = await opportunity.getSuggestions(); + + const fixEntityData = [ + { + type: 'INVALID_TYPE', // Invalid type + changeDetails: { + file: 'test.js', + }, + suggestions: [suggestions[0].getId()], + }, + ]; + + const result = await opportunity.addFixEntities(fixEntityData); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(0); + expect(result.errorItems).to.be.an('array').with.length(1); + expect(result.errorItems[0].error).to.be.an.instanceOf(ValidationError); + }); + + it('creates fix entities across multiple opportunities', async () => { + const opportunity1 = await Opportunity.findById(sampleData.opportunities[2].getId()); + const opportunity2 = await Opportunity.findById(sampleData.opportunities[1].getId()); + + const suggestions1 = await opportunity1.getSuggestions(); + const suggestions2 = await opportunity2.getSuggestions(); + + const fixEntityData1 = [ + { + type: 'CODE_CHANGE', + changeDetails: { file: 'test1.js' }, + suggestions: [suggestions1[0].getId()], + }, + ]; + + const fixEntityData2 = [ + { + type: 'CONTENT_UPDATE', + changeDetails: { file: 'test2.md' }, + suggestions: [suggestions2[0].getId()], + }, + ]; + + const result1 = await opportunity1.addFixEntities(fixEntityData1); + const result2 = await opportunity2.addFixEntities(fixEntityData2); + + expect(result1.createdItems).to.have.length(1); + expect(result2.createdItems).to.have.length(1); + + // Verify they belong to different opportunities + expect(result1.createdItems[0].getOpportunityId()).to.equal(opportunity1.getId()); + expect(result2.createdItems[0].getOpportunityId()).to.equal(opportunity2.getId()); + }); + }); }); 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 index f9ceeea23..f93dbfdd1 100644 --- a/packages/spacecat-shared-data-access/test/it/site/site.test.js +++ b/packages/spacecat-shared-data-access/test/it/site/site.test.js @@ -147,6 +147,102 @@ describe('Site IT', async () => { expect(exists).to.be.false; }); + it('batch gets multiple sites by keys', async () => { + const keys = [ + { siteId: sampleData.sites[0].getId() }, + { siteId: sampleData.sites[1].getId() }, + { siteId: sampleData.sites[2].getId() }, + ]; + + const result = await Site.batchGetByKeys(keys); + + expect(result).to.be.an('object'); + expect(result.data).to.be.an('array'); + expect(result.data.length).to.equal(3); + expect(result.unprocessed).to.be.an('array'); + expect(result.unprocessed.length).to.equal(0); + + // Verify each site is returned correctly + const returnedIds = result.data.map((site) => site.getId()).sort(); + const expectedIds = [ + sampleData.sites[0].getId(), + sampleData.sites[1].getId(), + sampleData.sites[2].getId(), + ].sort(); + + expect(returnedIds).to.deep.equal(expectedIds); + + // Verify site objects are fully populated + for (let i = 0; i < result.data.length; i += 1) { + await checkSite(result.data[i]); + } + }); + + it('batch gets sites with attributes option', async () => { + const keys = [ + { siteId: sampleData.sites[0].getId() }, + { siteId: sampleData.sites[1].getId() }, + ]; + + // Request only specific attributes + const result = await Site.batchGetByKeys(keys, { + attributes: ['siteId', 'baseURL', 'deliveryType'], + }); + + expect(result).to.be.an('object'); + expect(result.data).to.be.an('array'); + expect(result.data.length).to.equal(2); + expect(result.unprocessed).to.be.an('array'); + expect(result.unprocessed.length).to.equal(0); + + // Verify sites are returned with only requested attributes + result.data.forEach((site) => { + const json = site.toJSON(); + + // Verify requested attributes ARE present + expect(json.siteId).to.be.a('string'); + expect(json.baseURL).to.be.a('string'); + expect(json.deliveryType).to.be.a('string'); + + // Verify other attributes are NOT present + expect(json.gitHubURL).to.be.undefined; + expect(json.name).to.be.undefined; + expect(json.organizationId).to.be.undefined; + expect(json.isLive).to.be.undefined; + expect(json.hlxConfig).to.be.undefined; + expect(json.createdAt).to.be.undefined; + expect(json.updatedAt).to.be.undefined; + + // Verify we only have the exact number of attributes we requested + // (plus internal ElectroDB attributes that start with __) + const userAttributes = Object.keys(json).filter((key) => !key.startsWith('__')); + expect(userAttributes.length).to.equal(3); + }); + }); + + it('batch gets sites handles non-existent keys', async () => { + const keys = [ + { siteId: sampleData.sites[0].getId() }, + { siteId: 'non-existent-id-12345' }, + { siteId: sampleData.sites[1].getId() }, + ]; + + const result = await Site.batchGetByKeys(keys); + + expect(result).to.be.an('object'); + expect(result.data).to.be.an('array'); + // Should return only the 2 existing sites + expect(result.data.length).to.equal(2); + + const returnedIds = result.data.map((site) => site.getId()).sort(); + const expectedIds = [ + sampleData.sites[0].getId(), + sampleData.sites[1].getId(), + ].sort(); + + expect(returnedIds).to.deep.equal(expectedIds); + }); + it('gets all audits for a site', async () => { const site = await Site.findById(sampleData.sites[1].getId()); const audits = await site.getAudits(); 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 index 7146e0ff9..46f78b35f 100644 --- a/packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js +++ b/packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js @@ -17,23 +17,26 @@ import { isIsoDate, isValidUUID } from '@adobe/spacecat-shared-utils'; import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import { ValidationError } from '../../../src/index.js'; import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/util/util.js'; import { getDataAccess } from '../util/db.js'; import { seedDatabase } from '../util/seed.js'; +import ValidationError from '../../../src/errors/validation.error.js'; use(chaiAsPromised); describe('Suggestion IT', async () => { let sampleData; let Suggestion; + let FixEntitySuggestion; - before(async () => { + beforeEach(async function () { + this.timeout(10000); sampleData = await seedDatabase(); const dataAccess = getDataAccess(); Suggestion = dataAccess.Suggestion; + FixEntitySuggestion = dataAccess.FixEntitySuggestion; }); it('finds one suggestion by id', async () => { @@ -254,4 +257,49 @@ describe('Suggestion IT', async () => { const notFound = await Suggestion.findById(sampleData.suggestions[0].getId()); expect(notFound).to.be.null; }); + + it('gets fix entities for a single suggestion ID', async () => { + const suggestion = sampleData.suggestions[2]; + const fixEntityIds = [ + sampleData.fixEntities[0].getId(), + sampleData.fixEntities[2].getId(), + ]; + + // First, set up some fix entities for this suggestion using direct junction records + const junctionData = fixEntityIds.map((fixEntityId, index) => ({ + suggestionId: suggestion.getId(), + fixEntityId, + opportunityId: sampleData.fixEntities[index * 2].getOpportunityId(), + fixEntityCreatedAt: sampleData.fixEntities[index * 2].getCreatedAt(), + })); + await FixEntitySuggestion.createMany(junctionData); + + // Test the single suggestion method + const retrievedFixEntities = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); + + expect(retrievedFixEntities).to.be.an('array').with.length(2); + retrievedFixEntities.forEach((fixEntity) => { + expect(fixEntity).to.be.an('object'); + expect(fixEntity.getId()).to.be.a('string'); + expect(fixEntity.getOpportunityId()).to.be.a('string'); + expect(fixEntity.getStatus()).to.be.a('string'); + expect(fixEntity.getType()).to.be.a('string'); + expect(fixEntityIds).to.include(fixEntity.getId()); + }); + }); + + it('handles non-existent suggestion ID in single operations', async () => { + const nonExistentId = '123e4567-e89b-12d3-a456-426614174999'; + + const fixEntities = await Suggestion.getFixEntitiesBySuggestionId(nonExistentId); + expect(fixEntities).to.be.an('array').with.length(0); + }); + + it('validates suggestion ID in single operations', async () => { + const invalidId = 'invalid-id'; + + await expect( + Suggestion.getFixEntitiesBySuggestionId(invalidId), + ).to.be.rejectedWith('Validation failed'); + }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/models/base/base.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/base/base.collection.test.js index ca325f7cc..b5bd4036a 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/base/base.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/base/base.collection.test.js @@ -16,7 +16,7 @@ import { expect, use as chaiUse } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { ElectroValidationError } from 'electrodb'; -import { spy, stub } from 'sinon'; +import sinon, { spy, stub } from 'sinon'; import sinonChai from 'sinon-chai'; import BaseCollection from '../../../../src/models/base/base.collection.js'; @@ -986,4 +986,456 @@ describe('BaseCollection', () => { ]); }); }); + + describe('batchGetByKeys', () => { + it('should successfully batch get entities by keys', async () => { + const keys = [ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ]; + const mockRecords = [ + { ...mockRecord, mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { ...mockRecord, mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ]; + + const mockElectroResult = { + data: mockRecords, + unprocessed: [], + }; + + mockElectroService.entities.mockEntityModel.get.returns({ + go: stub().resolves(mockElectroResult), + }); + + const result = await baseCollectionInstance.batchGetByKeys(keys); + + expect(result.data).to.have.length(2); + expect(result.data[0].record.mockEntityModelId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); + expect(result.data[1].record.mockEntityModelId).to.equal('ef39921f-9a02-41db-b491-02c98987d957'); + expect(result.unprocessed).to.deep.equal([]); + + expect(mockElectroService.entities.mockEntityModel.get).to.have.been.calledOnceWith(keys); + }); + + it('should handle partial results with unprocessed items', async () => { + const keys = [ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d958' }, + ]; + const mockRecords = [ + { ...mockRecord, mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { ...mockRecord, mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ]; + + const mockElectroResult = { + data: mockRecords, + unprocessed: [{ mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d958' }], + }; + + mockElectroService.entities.mockEntityModel.get.returns({ + go: stub().resolves(mockElectroResult), + }); + + const result = await baseCollectionInstance.batchGetByKeys(keys); + + expect(result.data).to.have.length(2); + expect(result.data[0].record.mockEntityModelId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); + expect(result.data[1].record.mockEntityModelId).to.equal('ef39921f-9a02-41db-b491-02c98987d957'); + expect(result.unprocessed).to.deep.equal([{ mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d958' }]); + + expect(result.data).to.have.length(2); + expect(result.unprocessed).to.have.length(1); + }); + + it('should return empty arrays when no entities found', async () => { + const keys = [{ mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d999' }]; + + const mockElectroResult = { + data: [], + unprocessed: [{ mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d999' }], + }; + + mockElectroService.entities.mockEntityModel.get.returns({ + go: stub().resolves(mockElectroResult), + }); + + const result = await baseCollectionInstance.batchGetByKeys(keys); + + expect(result).to.deep.equal({ + data: [], + unprocessed: [{ mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d999' }], + }); + }); + + it('should throw error when keys is not provided', async () => { + await expect(baseCollectionInstance.batchGetByKeys()).to.be.rejectedWith(DataAccessError); + }); + + it('should throw error when keys is not an array', async () => { + await expect(baseCollectionInstance.batchGetByKeys('not-an-array')).to.be.rejectedWith(DataAccessError); + }); + + it('should throw error when keys is an empty array', async () => { + await expect(baseCollectionInstance.batchGetByKeys([])).to.be.rejectedWith(DataAccessError); + }); + + it('should throw error when keys contains null values', async () => { + await expect(baseCollectionInstance.batchGetByKeys([ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + null, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ])).to.be.rejectedWith(DataAccessError); + }); + + it('should throw error when keys contains undefined values', async () => { + await expect(baseCollectionInstance.batchGetByKeys([ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + undefined, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ])).to.be.rejectedWith(DataAccessError); + }); + + it('should throw error when keys contains empty objects', async () => { + await expect(baseCollectionInstance.batchGetByKeys([ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + {}, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ])).to.be.rejectedWith(DataAccessError); + }); + + it('should throw error when keys contains non-object values', async () => { + await expect(baseCollectionInstance.batchGetByKeys([ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + 'not-an-object', + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ])).to.be.rejectedWith(DataAccessError); + }); + + it('should handle database errors and throw DataAccessError', async () => { + const keys = [{ mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }]; + const error = new Error('Database connection failed'); + + mockElectroService.entities.mockEntityModel.get.returns({ + go: stub().rejects(error), + }); + + await expect(baseCollectionInstance.batchGetByKeys(keys)).to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to batch get by keys [mockEntityModel]', error); + }); + + it('should handle null records in results', async () => { + const keys = [ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ]; + const mockRecords = [ + { ...mockRecord, mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { ...mockRecord, mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ]; + + const mockElectroResult = { + data: mockRecords, + unprocessed: [], + }; + + mockElectroService.entities.mockEntityModel.get.returns({ + go: stub().resolves(mockElectroResult), + }); + + const result = await baseCollectionInstance.batchGetByKeys(keys); + + expect(result.data).to.have.length(2); + expect(result.unprocessed).to.deep.equal([]); + }); + + it('should handle large batch sizes', async () => { + const keys = Array.from({ length: 100 }, (_, i) => ({ + mockEntityModelId: `ef39921f-9a02-41db-b491-02c98987d${i.toString().padStart(3, '0')}`, + })); + const mockRecords = keys + .map((key) => ({ ...mockRecord, mockEntityModelId: key.mockEntityModelId })); + + const mockElectroResult = { + data: mockRecords, + unprocessed: [], + }; + + mockElectroService.entities.mockEntityModel.get.returns({ + go: stub().resolves(mockElectroResult), + }); + + const result = await baseCollectionInstance.batchGetByKeys(keys); + + expect(result.data).to.have.length(100); + expect(result.unprocessed).to.have.length(0); + expect(mockElectroService.entities.mockEntityModel.get).to.have.been.calledOnce; + }); + + it('should handle mixed valid and invalid keys', async () => { + const keys = [ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + 'not-an-object', + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + null, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d958' }, + ]; + + await expect(baseCollectionInstance.batchGetByKeys(keys)).to.be.rejectedWith(DataAccessError); + }); + + it('should log error and throw DataAccessError on validation failure', async () => { + const keys = [ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { invalidKey: 'invalid-format' }, + ]; + + await expect(baseCollectionInstance.batchGetByKeys(keys)).to.be.rejectedWith(DataAccessError); + }); + + it('should support attributes option', async () => { + const keys = [ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + ]; + const mockRecords = [ + { ...mockRecord, mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + ]; + + const mockElectroResult = { + data: mockRecords, + unprocessed: [], + }; + + const goStub = stub().resolves(mockElectroResult); + mockElectroService.entities.mockEntityModel.get.returns({ + go: goStub, + }); + + const result = await baseCollectionInstance.batchGetByKeys(keys, { attributes: ['mockEntityModelId', 'name'] }); + + expect(result.data).to.have.length(1); + expect(goStub).to.have.been.calledOnceWith({ attributes: ['mockEntityModelId', 'name'] }); + }); + + it('should work without options (backward compatibility)', async () => { + const keys = [ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + ]; + const mockRecords = [ + { ...mockRecord, mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + ]; + + const mockElectroResult = { + data: mockRecords, + unprocessed: [], + }; + + const goStub = stub().resolves(mockElectroResult); + mockElectroService.entities.mockEntityModel.get.returns({ + go: goStub, + }); + + const result = await baseCollectionInstance.batchGetByKeys(keys); + + expect(result.data).to.have.length(1); + expect(goStub).to.have.been.calledOnceWith({}); + }); + }); + + describe('removeByIndexKeys', () => { + let mockDeleteQuery; + + beforeEach(() => { + mockDeleteQuery = { + go: stub().resolves(), + }; + mockElectroService.entities.mockEntityModel.delete = stub().returns(mockDeleteQuery); + }); + + it('should remove records using array of single key objects', async () => { + const keys = [{ someKey: 'test-value' }]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + expect(mockElectroService.entities.mockEntityModel.delete).to.have.been.calledOnceWith(keys); + expect(mockDeleteQuery.go).to.have.been.calledOnce; + expect(mockLogger.info).to.have.been.calledWith(`Removed ${keys.length} items for [mockEntityModel]`); + }); + + it('should remove records using array of composite key objects', async () => { + const keys = [{ someKey: 'test-value', someOtherKey: 123 }]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + expect(mockElectroService.entities.mockEntityModel.delete).to.have.been.calledOnceWith(keys); + expect(mockDeleteQuery.go).to.have.been.calledOnce; + }); + + it('should remove records using array of multiple key objects', async () => { + const keys = [ + { someKey: 'test-value-1' }, + { someKey: 'test-value-2' }, + ]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + expect(mockElectroService.entities.mockEntityModel.delete).to.have.been.calledOnceWith(keys); + expect(mockDeleteQuery.go).to.have.been.calledOnce; + expect(mockLogger.info).to.have.been.calledWith(`Removed ${keys.length} items for [mockEntityModel]`); + }); + + it('should invalidate cache after successful removal', async () => { + const keys = [{ someKey: 'test-value' }]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + // Cache invalidation happens internally, just verify the method completes successfully + expect(mockElectroService.entities.mockEntityModel.delete).to.have.been.calledOnceWith(keys); + expect(mockDeleteQuery.go).to.have.been.calledOnce; + }); + + it('should throw DataAccessError when keys is null', async () => { + await expect(baseCollectionInstance.removeByIndexKeys(null)) + .to.be.rejectedWith(DataAccessError, 'keys must be a non-empty array'); + + expect(mockLogger.error).to.have.been.calledWith( + 'Failed to remove by index keys [mockEntityModel]: keys must be a non-empty array', + ); + }); + + it('should throw DataAccessError when keys is undefined', async () => { + await expect(baseCollectionInstance.removeByIndexKeys(undefined)) + .to.be.rejectedWith(DataAccessError, 'keys must be a non-empty array'); + }); + + it('should throw DataAccessError when keys is not an array', async () => { + await expect(baseCollectionInstance.removeByIndexKeys({ someKey: 'test-value' })) + .to.be.rejectedWith(DataAccessError, 'keys must be a non-empty array'); + }); + + it('should throw DataAccessError when keys is empty array', async () => { + await expect(baseCollectionInstance.removeByIndexKeys([])) + .to.be.rejectedWith(DataAccessError, 'keys must be a non-empty array'); + }); + + it('should throw DataAccessError when array contains empty objects', async () => { + await expect(baseCollectionInstance.removeByIndexKeys([{}])) + .to.be.rejectedWith(DataAccessError, 'key must be a non-empty object'); + + expect(mockLogger.error).to.have.been.calledWith( + 'Failed to remove by index keys [mockEntityModel]: key must be a non-empty object', + ); + }); + + it('should throw DataAccessError when array contains null values', async () => { + await expect(baseCollectionInstance.removeByIndexKeys([null])) + .to.be.rejectedWith(DataAccessError, 'key must be a non-empty object'); + }); + + it('should handle database errors gracefully', async () => { + const keys = [{ someKey: 'test-value' }]; + const dbError = new Error('Database connection failed'); + mockDeleteQuery.go.rejects(dbError); + + await expect(baseCollectionInstance.removeByIndexKeys(keys)) + .to.be.rejectedWith(DataAccessError, 'Failed to remove by index keys'); + + // The error logging uses the format "Base Collection Error [entityName]" + expect(mockLogger.error).to.have.been.calledWith( + 'Base Collection Error [mockEntityModel]', + sinon.match.instanceOf(DataAccessError), + ); + }); + + it('should handle ElectroValidationError', async () => { + const keys = [{ someKey: 'test-value' }]; + const validationError = new ElectroValidationError('Invalid key format'); + mockDeleteQuery.go.rejects(validationError); + + await expect(baseCollectionInstance.removeByIndexKeys(keys)) + .to.be.rejectedWith(DataAccessError, 'Failed to remove by index keys'); + }); + + it('should log successful removal with correct count for array', async () => { + const keys = [ + { someKey: 'test-value-1' }, + { someKey: 'test-value-2' }, + { someKey: 'test-value-3' }, + ]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + expect(mockLogger.info).to.have.been.calledWith( + `Removed ${keys.length} items for [mockEntityModel]`, + ); + }); + + it('should work with complex composite keys', async () => { + const keys = [{ + partitionKey: 'partition-value', + sortKey: 'sort-value', + gsiKey: 'gsi-value', + }]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + expect(mockElectroService.entities.mockEntityModel.delete).to.have.been.calledOnceWith(keys); + expect(mockDeleteQuery.go).to.have.been.calledOnce; + }); + + it('should handle mixed key types in array', async () => { + const keys = [ + { someKey: 'string-value' }, + { someOtherKey: 123 }, + { someKey: 'another-string', someOtherKey: 456 }, + ]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + expect(mockElectroService.entities.mockEntityModel.delete).to.have.been.calledOnceWith(keys); + expect(mockDeleteQuery.go).to.have.been.calledOnce; + }); + + it('should work with boolean values in keys', async () => { + const keys = [{ isActive: true, isDeleted: false }]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + expect(mockElectroService.entities.mockEntityModel.delete).to.have.been.calledOnceWith(keys); + expect(mockDeleteQuery.go).to.have.been.calledOnce; + }); + + it('should work with date values in keys', async () => { + const testDate = new Date('2024-01-01T00:00:00Z'); + const keys = [{ createdAt: testDate }]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + expect(mockElectroService.entities.mockEntityModel.delete).to.have.been.calledOnceWith(keys); + expect(mockDeleteQuery.go).to.have.been.calledOnce; + }); + + it('should preserve key order in deletion call', async () => { + const keys = [{ + firstKey: 'first-value', + secondKey: 'second-value', + thirdKey: 'third-value', + }]; + + await baseCollectionInstance.removeByIndexKeys(keys); + + const deleteCall = mockElectroService.entities.mockEntityModel.delete.getCall(0); + expect(deleteCall.args[0]).to.deep.equal(keys); + }); + + it('should validate each key object in the array', async () => { + const keys = [ + { someKey: 'valid-key' }, + {}, // This should cause an error + ]; + + await expect(baseCollectionInstance.removeByIndexKeys(keys)) + .to.be.rejectedWith(DataAccessError, 'key must be a non-empty object'); + }); + }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/models/base/base.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/base/base.model.test.js index df1be2ec6..e8435a419 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/base/base.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/base/base.model.test.js @@ -207,6 +207,7 @@ describe('BaseModel', () => { /* eslint-disable no-underscore-dangle */ mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(collectionMethods); mockEntityRegistry.getCollection.withArgs('FixEntityCollection').returns(collectionMethods); + mockEntityRegistry.getCollection.withArgs('FixEntitySuggestionCollection').returns(collectionMethods); mockEntityRegistry.getCollection.withArgs('SomeModelCollection').returns(collectionMethods); mockElectroService.entities.opportunity.remove.returns({ go: () => Promise.resolve() }); }); @@ -223,16 +224,23 @@ describe('BaseModel', () => { /* eslint-disable no-underscore-dangle */ }); it('removes record with dependents', async () => { - const reference = Reference.fromJSON({ + const hasOneReference = Reference.fromJSON({ type: Reference.TYPES.HAS_ONE, target: 'SomeModel', options: { removeDependents: true }, }); + const hasManyReference = Reference.fromJSON({ + type: Reference.TYPES.HAS_MANY, + target: 'Suggestions', + options: { removeDependents: true }, + }); + baseModelInstance.getSomeModel = stub().resolves(dependent); baseModelInstance.getSuggestions = stub().resolves(dependents); - schema.references.push(reference); + // Clear existing references and add the ones we're testing + schema.references = [hasOneReference, hasManyReference]; await expect(baseModelInstance.remove()).to.eventually.equal(baseModelInstance); @@ -241,6 +249,7 @@ describe('BaseModel', () => { /* eslint-disable no-underscore-dangle */ // dependents remove: 3 = has_many, 1 = has_one expect(dependent._remove).to.have.callCount(4); expect(baseModelInstance.getSomeModel).to.have.been.calledOnce; + expect(baseModelInstance.getSuggestions).to.have.been.calledOnce; expect(mockLogger.error).to.not.have.been.called; }); diff --git a/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.collection.test.js new file mode 100644 index 000000000..a9d662de9 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.collection.test.js @@ -0,0 +1,383 @@ +/* + * Copyright 2025 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 } from 'chai'; +import { stub } from 'sinon'; + +import FixEntitySuggestion from '../../../../src/models/fix-entity-suggestion/fix-entity-suggestion.model.js'; +import { createElectroMocks } from '../../util.js'; + +describe('FixEntitySuggestionCollection', () => { + let collection; + + const mockRecord = { + suggestionId: 'suggestion-123', + fixEntityId: 'fix-456', + fixEntityCreatedAt: '2024-01-01T00:00:00.000Z', + fixEntityCreatedDate: '2024-01-01', + }; + + beforeEach(() => { + ({ + collection, + } = createElectroMocks(FixEntitySuggestion, mockRecord)); + + // Stub the inherited methods that we want to test + collection.allByIndexKeys = stub(); + }); + + describe('allBySuggestionId', () => { + it('should get all junction records for a suggestion ID', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174000'; + const expectedRecords = [ + { + suggestionId, fixEntityId: 'fix-1', fixEntityCreatedAt: '2024-01-01T00:00:00.000Z', fixEntityCreatedDate: '2024-01-01', + }, + { + suggestionId, fixEntityId: 'fix-2', fixEntityCreatedAt: '2024-01-02T00:00:00.000Z', fixEntityCreatedDate: '2024-01-02', + }, + ]; + + collection.allByIndexKeys.resolves(expectedRecords); + + const result = await collection.allBySuggestionId(suggestionId); + + expect(collection.allByIndexKeys).to.have.been.calledOnceWith({ suggestionId }); + expect(result).to.deep.equal(expectedRecords); + }); + + it('should throw error when suggestionId is not provided', async () => { + await expect(collection.allBySuggestionId(null)) + .to.be.rejectedWith('suggestionId must be a valid UUID'); + + await expect(collection.allBySuggestionId('')) + .to.be.rejectedWith('suggestionId must be a valid UUID'); + + await expect(collection.allBySuggestionId(undefined)) + .to.be.rejectedWith('suggestionId must be a valid UUID'); + }); + + it('should handle empty results', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174001'; + collection.allByIndexKeys.resolves([]); + + const result = await collection.allBySuggestionId(suggestionId); + + expect(result).to.be.an('array').that.is.empty; + }); + }); + + describe('allByOpportunityIdAndFixEntityCreatedDate', () => { + it('should get all junction records for an opportunity ID and fix entity created date', async () => { + const opportunityId = '123e4567-e89b-12d3-a456-426614174002'; + const fixEntityCreatedDate = '2024-01-15'; + const expectedRecords = [ + { + opportunityId, + suggestionId: '123e4567-e89b-12d3-a456-426614174003', + fixEntityId: '123e4567-e89b-12d3-a456-426614174004', + fixEntityCreatedAt: '2024-01-15T10:30:00.000Z', + fixEntityCreatedDate, + }, + { + opportunityId, + suggestionId: '123e4567-e89b-12d3-a456-426614174005', + fixEntityId: '123e4567-e89b-12d3-a456-426614174006', + fixEntityCreatedAt: '2024-01-15T14:45:00.000Z', + fixEntityCreatedDate, + }, + ]; + + collection.allByIndexKeys.resolves(expectedRecords); + + const result = await collection.allByOpportunityIdAndFixEntityCreatedDate( + opportunityId, + fixEntityCreatedDate, + ); + + expect(collection.allByIndexKeys).to.have.been.calledOnceWith({ + opportunityId, + fixEntityCreatedDate, + }); + expect(result).to.deep.equal(expectedRecords); + }); + + it('should throw error when opportunityId is not provided', async () => { + await expect(collection.allByOpportunityIdAndFixEntityCreatedDate(null, '2024-01-15')) + .to.be.rejectedWith('opportunityId is required'); + + await expect(collection.allByOpportunityIdAndFixEntityCreatedDate('', '2024-01-15')) + .to.be.rejectedWith('opportunityId is required'); + + await expect(collection.allByOpportunityIdAndFixEntityCreatedDate(undefined, '2024-01-15')) + .to.be.rejectedWith('opportunityId is required'); + }); + + it('should throw error when fixEntityCreatedDate is not provided', async () => { + const opportunityId = '123e4567-e89b-12d3-a456-426614174007'; + + await expect(collection.allByOpportunityIdAndFixEntityCreatedDate(opportunityId, null)) + .to.be.rejectedWith('fixEntityCreatedDate is required'); + + await expect(collection.allByOpportunityIdAndFixEntityCreatedDate(opportunityId, '')) + .to.be.rejectedWith('fixEntityCreatedDate is required'); + + await expect(collection.allByOpportunityIdAndFixEntityCreatedDate(opportunityId, undefined)) + .to.be.rejectedWith('fixEntityCreatedDate is required'); + }); + + it('should handle empty results', async () => { + const opportunityId = '123e4567-e89b-12d3-a456-426614174008'; + const fixEntityCreatedDate = '2024-01-20'; + collection.allByIndexKeys.resolves([]); + + const result = await collection.allByOpportunityIdAndFixEntityCreatedDate( + opportunityId, + fixEntityCreatedDate, + ); + + expect(result).to.be.an('array').that.is.empty; + }); + + it('should pass options parameter to allByIndexKeys', async () => { + const opportunityId = '123e4567-e89b-12d3-a456-426614174009'; + const fixEntityCreatedDate = '2024-01-15'; + const options = { limit: 10, cursor: 'some-cursor' }; + const expectedRecords = []; + + collection.allByIndexKeys.resolves(expectedRecords); + + await collection.allByOpportunityIdAndFixEntityCreatedDate( + opportunityId, + fixEntityCreatedDate, + options, + ); + + expect(collection.allByIndexKeys).to.have.been.calledOnce; + const callArgs = collection.allByIndexKeys.getCall(0).args; + expect(callArgs[0]).to.deep.equal({ opportunityId, fixEntityCreatedDate }); + expect(callArgs[1]).to.include(options); + }); + }); + + describe('allByFixEntityId', () => { + it('should get all junction records for a fix entity ID', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174002'; + const expectedRecords = [ + { + suggestionId: '123e4567-e89b-12d3-a456-426614174003', fixEntityId, fixEntityCreatedAt: '2024-01-01T00:00:00.000Z', fixEntityCreatedDate: '2024-01-01', + }, + { + suggestionId: '123e4567-e89b-12d3-a456-426614174004', fixEntityId, fixEntityCreatedAt: '2024-01-02T00:00:00.000Z', fixEntityCreatedDate: '2024-01-02', + }, + ]; + + collection.allByIndexKeys.resolves(expectedRecords); + + const result = await collection.allByFixEntityId(fixEntityId); + + expect(collection.allByIndexKeys).to.have.been.calledOnceWith({ fixEntityId }); + expect(result).to.deep.equal(expectedRecords); + }); + + it('should throw error when fixEntityId is not provided', async () => { + await expect(collection.allByFixEntityId(null)) + .to.be.rejectedWith('fixEntityId is required'); + + await expect(collection.allByFixEntityId('')) + .to.be.rejectedWith('fixEntityId is required'); + + await expect(collection.allByFixEntityId(undefined)) + .to.be.rejectedWith('fixEntityId is required'); + }); + + it('should handle empty results', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174005'; + collection.allByIndexKeys.resolves([]); + + const result = await collection.allByFixEntityId(fixEntityId); + + expect(result).to.be.an('array').that.is.empty; + }); + }); + + describe('removeByIndexKeys (inherited from BaseCollection)', () => { + beforeEach(() => { + // Mock the inherited removeByIndexKeys method + collection.removeByIndexKeys = stub(); + }); + + it('should remove junction records by suggestion ID', async () => { + const keys = { suggestionId: 'suggestion-123' }; + collection.removeByIndexKeys.resolves(); + + await collection.removeByIndexKeys(keys); + + expect(collection.removeByIndexKeys).to.have.been.calledOnceWith(keys); + }); + + it('should remove junction records by fix entity ID', async () => { + const keys = { fixEntityId: 'fix-123' }; + collection.removeByIndexKeys.resolves(); + + await collection.removeByIndexKeys(keys); + + expect(collection.removeByIndexKeys).to.have.been.calledOnceWith(keys); + }); + + it('should remove junction records by composite keys', async () => { + const keys = { suggestionId: 'suggestion-123', fixEntityId: 'fix-456' }; + collection.removeByIndexKeys.resolves(); + + await collection.removeByIndexKeys(keys); + + expect(collection.removeByIndexKeys).to.have.been.calledOnceWith(keys); + }); + + it('should handle array of key objects for batch removal', async () => { + const keyArray = [ + { suggestionId: 'suggestion-1', fixEntityId: 'fix-1' }, + { suggestionId: 'suggestion-2', fixEntityId: 'fix-2' }, + ]; + collection.removeByIndexKeys.resolves(); + + await collection.removeByIndexKeys(keyArray); + + expect(collection.removeByIndexKeys).to.have.been.calledOnceWith(keyArray); + }); + }); + + describe('integration with BaseCollection methods', () => { + beforeEach(() => { + // Restore the actual inherited methods for integration testing + collection.createMany = stub(); + collection.batchGetByIds = stub(); + collection.removeByIds = stub(); + }); + + it('should support createMany for bulk junction record creation', async () => { + const junctionRecords = [ + { + suggestionId: 'suggestion-1', fixEntityId: 'fix-1', fixEntityCreatedAt: '2024-01-01T00:00:00.000Z', fixEntityCreatedDate: '2024-01-01', + }, + { + suggestionId: 'suggestion-1', fixEntityId: 'fix-2', fixEntityCreatedAt: '2024-01-02T00:00:00.000Z', fixEntityCreatedDate: '2024-01-02', + }, + ]; + + const expectedResult = { + createdItems: junctionRecords, + errorItems: [], + }; + + collection.createMany.resolves(expectedResult); + + const result = await collection.createMany(junctionRecords); + + expect(collection.createMany).to.have.been.calledOnceWith(junctionRecords); + expect(result).to.deep.equal(expectedResult); + }); + + it('should support batchGetByIds for retrieving multiple junction records', async () => { + const ids = ['junction-1', 'junction-2']; + const expectedResult = { + data: [ + { + id: 'junction-1', suggestionId: 'suggestion-1', fixEntityId: 'fix-1', fixEntityCreatedAt: '2024-01-01T00:00:00.000Z', fixEntityCreatedDate: '2024-01-01', + }, + { + id: 'junction-2', suggestionId: 'suggestion-1', fixEntityId: 'fix-2', fixEntityCreatedAt: '2024-01-02T00:00:00.000Z', fixEntityCreatedDate: '2024-01-02', + }, + ], + unprocessed: [], + }; + + collection.batchGetByIds.resolves(expectedResult); + + const result = await collection.batchGetByIds(ids); + + expect(collection.batchGetByIds).to.have.been.calledOnceWith(ids); + expect(result).to.deep.equal(expectedResult); + }); + + it('should support removeByIds for bulk deletion by junction record IDs', async () => { + const ids = ['junction-1', 'junction-2']; + collection.removeByIds.resolves(); + + await collection.removeByIds(ids); + + expect(collection.removeByIds).to.have.been.calledOnceWith(ids); + }); + }); + + describe('error handling', () => { + it('should propagate errors from allByIndexKeys in allBySuggestionId', async () => { + const error = new Error('Database connection failed'); + collection.allByIndexKeys.rejects(error); + + await expect(collection.allBySuggestionId('123e4567-e89b-12d3-a456-426614174006')) + .to.be.rejectedWith('Database connection failed'); + }); + + it('should propagate errors from allByIndexKeys in allByOpportunityIdAndFixEntityCreatedDate', async () => { + const error = new Error('Index not found'); + collection.allByIndexKeys.rejects(error); + + await expect( + collection.allByOpportunityIdAndFixEntityCreatedDate( + '123e4567-e89b-12d3-a456-426614174007', + '2024-01-15', + ), + ).to.be.rejectedWith('Index not found'); + }); + }); + + describe('performance considerations', () => { + it('should use efficient index queries for large datasets', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174008'; + const largeResultSet = Array.from({ length: 1000 }, (_, i) => ({ + suggestionId, + fixEntityId: `123e4567-e89b-12d3-a456-426614174${String(i).padStart(3, '0')}`, + fixEntityCreatedAt: `2024-01-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`, + fixEntityCreatedDate: `2024-01-${String((i % 28) + 1).padStart(2, '0')}`, + })); + + collection.allByIndexKeys.resolves(largeResultSet); + + const result = await collection.allBySuggestionId(suggestionId); + + expect(result).to.have.length(1000); + expect(collection.allByIndexKeys).to.have.been.calledOnceWith({ suggestionId }); + }); + + it('should handle pagination through allByIndexKeys', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174009'; + + // Mock paginated results + collection.allByIndexKeys.resolves([ + { + suggestionId: '123e4567-e89b-12d3-a456-426614174010', fixEntityId, fixEntityCreatedAt: '2024-01-01T00:00:00.000Z', fixEntityCreatedDate: '2024-01-01', + }, + { + suggestionId: '123e4567-e89b-12d3-a456-426614174011', fixEntityId, fixEntityCreatedAt: '2024-01-02T00:00:00.000Z', fixEntityCreatedDate: '2024-01-02', + }, + ]); + + const result = await collection.allByFixEntityId(fixEntityId); + + expect(result).to.have.length(2); + expect(collection.allByIndexKeys).to.have.been.calledOnceWith({ fixEntityId }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.model.test.js new file mode 100644 index 000000000..83b577dff --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.model.test.js @@ -0,0 +1,186 @@ +/* + * Copyright 2025 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 { restore } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import FixEntitySuggestion from '../../../../src/models/fix-entity-suggestion/fix-entity-suggestion.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('FixEntitySuggestionModel', () => { + let instance; + let mockRecord; + + beforeEach(() => { + mockRecord = { + suggestionId: '123e4567-e89b-12d3-a456-426614174000', + fixEntityId: '123e4567-e89b-12d3-a456-426614174001', + fixEntityCreatedAt: '2024-01-01T00:00:00.000Z', + fixEntityCreatedDate: '2024-01-01', + updatedAt: '2024-01-01T00:00:00.000Z', + updatedBy: 'spacecat', + }; + + ({ + model: instance, + } = createElectroMocks(FixEntitySuggestion, mockRecord)); + }); + + afterEach(() => { + restore(); + }); + + describe('constructor', () => { + it('initializes the FixEntitySuggestion instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('generateCompositeKeys', () => { + it('should return composite keys with suggestionId and fixEntityId', () => { + const result = instance.generateCompositeKeys(); + + expect(result).to.be.an('object'); + expect(result).to.have.property('suggestionId'); + expect(result).to.have.property('fixEntityId'); + expect(result.suggestionId).to.equal(mockRecord.suggestionId); + expect(result.fixEntityId).to.equal(mockRecord.fixEntityId); + }); + + it('should return the same values as getSuggestionId and getFixEntityId methods', () => { + const result = instance.generateCompositeKeys(); + + expect(result.suggestionId).to.equal(instance.getSuggestionId()); + expect(result.fixEntityId).to.equal(instance.getFixEntityId()); + }); + + it('should handle different UUID values correctly', () => { + // Update the record with different UUIDs + instance.record.suggestionId = '987e6543-e21b-34c5-a654-426614174999'; + instance.record.fixEntityId = '456e7890-e12b-45d6-a789-426614174888'; + + const result = instance.generateCompositeKeys(); + + expect(result.suggestionId).to.equal('987e6543-e21b-34c5-a654-426614174999'); + expect(result.fixEntityId).to.equal('456e7890-e12b-45d6-a789-426614174888'); + }); + + it('should return undefined values when IDs are not set', () => { + // Create instance with undefined IDs + const emptyRecord = { + updatedAt: '2024-01-01T00:00:00.000Z', + updatedBy: 'spacecat', + }; + + const { model: emptyInstance } = createElectroMocks(FixEntitySuggestion, emptyRecord); + + const result = emptyInstance.generateCompositeKeys(); + + expect(result).to.be.an('object'); + expect(result).to.have.property('suggestionId'); + expect(result).to.have.property('fixEntityId'); + expect(result.suggestionId).to.be.undefined; + expect(result.fixEntityId).to.be.undefined; + }); + + it('should return null values when IDs are explicitly set to null', () => { + // Set IDs to null + instance.record.suggestionId = null; + instance.record.fixEntityId = null; + + const result = instance.generateCompositeKeys(); + + expect(result).to.be.an('object'); + expect(result.suggestionId).to.be.null; + expect(result.fixEntityId).to.be.null; + }); + }); + + describe('fixEntityCreatedAt methods', () => { + it('should get fixEntityCreatedAt value', () => { + const result = instance.getFixEntityCreatedAt(); + expect(result).to.equal(mockRecord.fixEntityCreatedAt); + }); + + it('should return undefined when fixEntityCreatedAt is not set', () => { + const emptyRecord = { + suggestionId: '123e4567-e89b-12d3-a456-426614174000', + fixEntityId: '123e4567-e89b-12d3-a456-426614174001', + updatedAt: '2024-01-01T00:00:00.000Z', + updatedBy: 'spacecat', + }; + + const { model: emptyInstance } = createElectroMocks(FixEntitySuggestion, emptyRecord); + expect(emptyInstance.getFixEntityCreatedAt()).to.be.undefined; + }); + }); + + describe('fixEntityCreatedDate methods', () => { + it('should get fixEntityCreatedDate value', () => { + const result = instance.getFixEntityCreatedDate(); + expect(result).to.equal(mockRecord.fixEntityCreatedDate); + }); + + it('should return undefined when fixEntityCreatedDate is not set', () => { + const emptyRecord = { + suggestionId: '123e4567-e89b-12d3-a456-426614174000', + fixEntityId: '123e4567-e89b-12d3-a456-426614174001', + fixEntityCreatedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + updatedBy: 'spacecat', + }; + + const { model: emptyInstance } = createElectroMocks(FixEntitySuggestion, emptyRecord); + expect(emptyInstance.getFixEntityCreatedDate()).to.be.undefined; + }); + }); + + describe('watch pattern for fixEntityCreatedDate', () => { + it('should have fixEntityCreatedDate set when fixEntityCreatedAt is provided', () => { + // Create a new instance with a different timestamp + const testRecord = { + suggestionId: '123e4567-e89b-12d3-a456-426614174000', + fixEntityId: '123e4567-e89b-12d3-a456-426614174001', + fixEntityCreatedAt: '2024-03-15T14:30:45.123Z', + fixEntityCreatedDate: '2024-03-15', // This should be set by the watch pattern + updatedAt: '2024-01-01T00:00:00.000Z', + updatedBy: 'spacecat', + }; + + const { model: testInstance } = createElectroMocks(FixEntitySuggestion, testRecord); + + // Both fields should be accessible + expect(testInstance.getFixEntityCreatedAt()).to.equal('2024-03-15T14:30:45.123Z'); + expect(testInstance.getFixEntityCreatedDate()).to.equal('2024-03-15'); + }); + + it('should handle undefined fixEntityCreatedAt gracefully', () => { + const emptyRecord = { + suggestionId: '123e4567-e89b-12d3-a456-426614174000', + fixEntityId: '123e4567-e89b-12d3-a456-426614174001', + updatedAt: '2024-01-01T00:00:00.000Z', + updatedBy: 'spacecat', + }; + + const { model: emptyInstance } = createElectroMocks(FixEntitySuggestion, emptyRecord); + expect(emptyInstance.getFixEntityCreatedDate()).to.be.undefined; + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/fix-entity/fix-entity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/fix-entity/fix-entity.collection.test.js index 16eb7eae0..25b612ac4 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/fix-entity/fix-entity.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/fix-entity/fix-entity.collection.test.js @@ -10,52 +10,685 @@ * 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 { expect } from 'chai'; +import sinon, { stub, restore } from 'sinon'; import FixEntity from '../../../../src/models/fix-entity/fix-entity.model.js'; - +import DataAccessError from '../../../../src/errors/data-access.error.js'; +import ValidationError from '../../../../src/errors/validation.error.js'; import { createElectroMocks } from '../../util.js'; -chaiUse(chaiAsPromised); -chaiUse(sinonChai); - describe('FixEntityCollection', () => { - let instance; - - let mockElectroService; + let fixEntityCollection; let mockEntityRegistry; let mockLogger; - let model; - let schema; const mockRecord = { - fixEntityId: 's12345', + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', + opportunityId: '123e4567-e89b-12d3-a456-426614174001', + type: 'SEO', + status: 'PENDING', + changeDetails: { field: 'title', oldValue: 'Old', newValue: 'New' }, + executedAt: '2024-01-01T00:00:00.000Z', + executedBy: 'user123', + publishedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', }; + // Mock entity objects + const mockOpportunity = { + getId: () => '123e4567-e89b-12d3-a456-426614174001', + }; + + const mockFixEntity = { + getId: () => '123e4567-e89b-12d3-a456-426614174000', + getCreatedAt: () => '2024-01-15T10:30:00.000Z', + }; + + const mockSuggestions = [ + { getId: () => 'suggestion-1' }, + { getId: () => 'suggestion-2' }, + ]; + beforeEach(() => { ({ - mockElectroService, mockEntityRegistry, mockLogger, - collection: instance, - model, - schema, + collection: fixEntityCollection, } = createElectroMocks(FixEntity, mockRecord)); }); - describe('constructor', () => { - it('initializes the FixEntityCollection 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); + afterEach(() => { + restore(); + }); + + describe('getSuggestionsByFixEntityId', () => { + it('should get suggestions for a fix entity', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const mockJunctionRecords = [ + { getSuggestionId: () => 'suggestion-1' }, + { getSuggestionId: () => 'suggestion-2' }, + ]; + const mockSuggestionData = [ + { id: 'suggestion-1', title: 'Suggestion 1' }, + { id: 'suggestion-2', title: 'Suggestion 2' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(mockJunctionRecords), + }; + + const mockSuggestionCollection = { + batchGetByKeys: stub().resolves({ + data: mockSuggestionData, + unprocessed: [], + }), + idName: 'suggestionId', + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + mockEntityRegistry.getCollection + .withArgs('SuggestionCollection') + .returns(mockSuggestionCollection); + + const result = await fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId); + + expect(result).to.deep.equal(mockSuggestionData); + + expect(mockFixEntitySuggestionCollection.allByFixEntityId) + .to.have.been.calledOnceWith(fixEntityId); + expect(mockSuggestionCollection.batchGetByKeys) + .to.have.been.calledOnceWith([ + { suggestionId: 'suggestion-1' }, + { suggestionId: 'suggestion-2' }, + ]); + }); + + it('should return empty arrays when no junction records found', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId); + + expect(result).to.deep.equal([]); + + expect(mockFixEntitySuggestionCollection.allByFixEntityId) + .to.have.been.calledOnceWith(fixEntityId); + }); + + it('should throw error when fixEntityId is not provided', async () => { + await expect(fixEntityCollection.getSuggestionsByFixEntityId()) + .to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntityId must be a valid UUID'); + }); + + it('should handle errors and throw DataAccessError', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const error = new Error('Database error'); + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().rejects(error), + removeByIndexKeys: stub().resolves(), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + await expect(fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId)) + .to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith(`Failed to get suggestions for fix entity: ${fixEntityId}`, error); + }); + + it('should handle errors in batchGetByKeys and throw DataAccessError', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const mockJunctionRecords = [ + { getSuggestionId: () => 'suggestion-1' }, + ]; + const error = new Error('Batch get failed'); + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(mockJunctionRecords), + }; + + const mockSuggestionCollection = { + batchGetByKeys: stub().rejects(error), + idName: 'suggestionId', + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + mockEntityRegistry.getCollection + .withArgs('SuggestionCollection') + .returns(mockSuggestionCollection); + + await expect(fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId)) + .to.be.rejectedWith(DataAccessError, 'Failed to get suggestions for fix entity'); + expect(mockLogger.error).to.have.been.calledWith(`Failed to get suggestions for fix entity: ${fixEntityId}`, error); + }); + }); + + describe('setSuggestionsForFixEntity', () => { + it('should set suggestions for a fix entity with delta updates', async () => { + const existingJunctionRecords = [ + { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-1' }, + { getId: () => 'junction-2', getSuggestionId: () => 'suggestion-3' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(existingJunctionRecords), + removeByIds: stub().resolves(), + removeByIndexKeys: stub().resolves([ + { id: 'junction-2' }, + ]), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-3' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); + + expect(result).to.deep.equal({ + createdItems: [{ id: 'junction-3' }], + errorItems: [], + removedCount: 1, + }); + + expect(mockFixEntitySuggestionCollection.allByFixEntityId) + .to.have.been.calledOnceWith('123e4567-e89b-12d3-a456-426614174000'); + expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([ + { + suggestionId: 'suggestion-3', + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', + }, + ]); + expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ + { + opportunityId: '123e4567-e89b-12d3-a456-426614174001', + fixEntityCreatedAt: '2024-01-15T10:30:00.000Z', + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', + suggestionId: 'suggestion-2', + }, + ]); + }); + + it('should throw error when opportunityId is not provided', async () => { + await expect( + fixEntityCollection.setSuggestionsForFixEntity(null, mockFixEntity, mockSuggestions), + ).to.be.rejectedWith('Validation failed in FixEntityCollection: opportunityId must be a valid UUID'); + }); + + it('should throw error when fixEntity is not provided', async () => { + await expect( + fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), null, mockSuggestions), + ).to.be.rejectedWith(ValidationError, 'fixEntity is required'); + }); + + it('should throw error when suggestions is not an array', async () => { + await expect(fixEntityCollection.setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, 'not-an-array')) + .to.be.rejectedWith('Validation failed in FixEntityCollection: suggestions must be an array'); + }); + + it('should handle errors and throw DataAccessError', async () => { + const error = new Error('Database error'); + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().rejects(error), + removeByIndexKeys: stub().resolves(), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + await expect( + fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions), + ).to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to set suggestions for fix entity', error); + }); + + it('should log info about the operation results', async () => { + const singleSuggestion = [{ getId: () => 'suggestion-1' }]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-1' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + await fixEntityCollection.setSuggestionsForFixEntity( + mockOpportunity.getId(), + mockFixEntity, + singleSuggestion, + ); + + expect(mockLogger.info).to.have.been.calledWith( + 'Set suggestions for fix entity 123e4567-e89b-12d3-a456-426614174000: removed 0, added 1, failed 0', + ); + }); + + it('should handle remove operation failure gracefully', async () => { + const existingJunctionRecords = [ + { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-3' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(existingJunctionRecords), + removeByIndexKeys: stub().rejects(new Error('Remove failed')), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-2' }, { id: 'junction-3' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); + + expect(result).to.deep.equal({ + createdItems: [{ id: 'junction-2' }, { id: 'junction-3' }], + errorItems: [], + removedCount: 0, // Failed operation results in 0 removed + }); + + expect(mockLogger.error).to.have.been.calledWith( + 'Remove operation failed:', + sinon.match.instanceOf(Error), + ); + }); + + it('should handle create operation failure gracefully', async () => { + const existingJunctionRecords = [ + { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-3' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(existingJunctionRecords), + removeByIndexKeys: stub().resolves([{ id: 'removed-1' }]), + createMany: stub().rejects(new Error('Create failed')), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); + + expect(result).to.deep.equal({ + createdItems: [], // Failed operation results in empty array + errorItems: [], // Failed operation results in empty array + removedCount: 1, + }); + + expect(mockLogger.error).to.have.been.calledWith( + 'Create operation failed:', + sinon.match.instanceOf(Error), + ); + }); + + it('should handle both operations failing gracefully', async () => { + const existingJunctionRecords = [ + { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-3' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(existingJunctionRecords), + removeByIndexKeys: stub().rejects(new Error('Remove failed')), + createMany: stub().rejects(new Error('Create failed')), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); + + expect(result).to.deep.equal({ + createdItems: [], + errorItems: [], + removedCount: 0, + }); + + expect(mockLogger.error).to.have.been.calledTwice; + expect(mockLogger.error).to.have.been.calledWith( + 'Remove operation failed:', + sinon.match.instanceOf(Error), + ); + expect(mockLogger.error).to.have.been.calledWith( + 'Create operation failed:', + sinon.match.instanceOf(Error), + ); + }); + + it('should handle empty suggestion array (remove all)', async () => { + const emptySuggestions = []; + + const existingJunctionRecords = [ + { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-1' }, + { getId: () => 'junction-2', getSuggestionId: () => 'suggestion-2' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(existingJunctionRecords), + removeByIndexKeys: stub().resolves([ + { id: 'junction-1' }, + { id: 'junction-2' }, + ]), + createMany: stub().resolves({ + createdItems: [], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, emptySuggestions); + + expect(result).to.deep.equal({ + createdItems: [], + errorItems: [], + removedCount: 2, + }); + + expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([ + { suggestionId: 'suggestion-1', fixEntityId: '123e4567-e89b-12d3-a456-426614174000' }, + { suggestionId: 'suggestion-2', fixEntityId: '123e4567-e89b-12d3-a456-426614174000' }, + ]); + expect(mockFixEntitySuggestionCollection.createMany).to.not.have.been.called; + }); + + it('should handle no existing relationships (create all)', async () => { + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); + + expect(result).to.deep.equal({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + removedCount: 0, + }); + + expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.not.have.been.called; + expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ + { + opportunityId: '123e4567-e89b-12d3-a456-426614174001', + fixEntityCreatedAt: '2024-01-15T10:30:00.000Z', + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', + suggestionId: 'suggestion-1', + }, + { + opportunityId: '123e4567-e89b-12d3-a456-426614174001', + fixEntityCreatedAt: '2024-01-15T10:30:00.000Z', + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', + suggestionId: 'suggestion-2', + }, + ]); + }); + + it('should handle duplicate suggestion IDs in input', async () => { + const duplicateSuggestions = [ + { getId: () => 'suggestion-1' }, + { getId: () => 'suggestion-1' }, + { getId: () => 'suggestion-2' }, + { getId: () => 'suggestion-2' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, duplicateSuggestions); + + expect(result).to.deep.equal({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + removedCount: 0, + }); + + // Should only create unique suggestions + expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ + { + opportunityId: '123e4567-e89b-12d3-a456-426614174001', + fixEntityCreatedAt: '2024-01-15T10:30:00.000Z', + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', + suggestionId: 'suggestion-1', + }, + { + opportunityId: '123e4567-e89b-12d3-a456-426614174001', + fixEntityCreatedAt: '2024-01-15T10:30:00.000Z', + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', + suggestionId: 'suggestion-2', + }, + ]); + }); + + it('should handle undefined promises (no operations needed)', async () => { + const singleSuggestion = [{ getId: () => 'suggestion-1' }]; + + const existingJunctionRecords = [ + { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-1' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(existingJunctionRecords), + removeByIndexKeys: stub().resolves(), + createMany: stub().resolves({ + createdItems: [], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, singleSuggestion); + + expect(result).to.deep.equal({ + createdItems: [], + errorItems: [], + removedCount: 0, + }); + + // No operations should be called since suggestions are identical + expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.not.have.been.called; + expect(mockFixEntitySuggestionCollection.createMany).to.not.have.been.called; + }); + }); + + describe('getAllFixesWithSuggestionByCreatedAt', () => { + it('should get all fixes with suggestions ordered by created date', async () => { + const opportunityId = '123e4567-e89b-12d3-a456-426614174001'; + const fixEntityCreatedDate = '2024-01-15'; + + const mockFixEntitySuggestions = [ + { + getFixEntityId: () => 'fix-1', + getSuggestionId: () => 'suggestion-1', + }, + { + getFixEntityId: () => 'fix-1', + getSuggestionId: () => 'suggestion-2', + }, + { + getFixEntityId: () => 'fix-2', + getSuggestionId: () => 'suggestion-3', + }, + ]; + + const mockFixEntities = { + data: [ + { + getId: () => 'fix-1', + getCreatedAt: () => '2024-01-15T10:30:00.000Z', + }, + { + getId: () => 'fix-2', + getCreatedAt: () => '2024-01-15T09:30:00.000Z', + }, + ], + }; + + const mockSuggestionsData = { + data: [ + { getId: () => 'suggestion-1', title: 'Suggestion 1' }, + { getId: () => 'suggestion-2', title: 'Suggestion 2' }, + { getId: () => 'suggestion-3', title: 'Suggestion 3' }, + ], + }; + + const mockFixEntitySuggestionCollection = { + allByOpportunityIdAndFixEntityCreatedDate: stub().resolves(mockFixEntitySuggestions), + }; + + const mockSuggestionCollection = { + batchGetByKeys: stub().resolves(mockSuggestionsData), + idName: 'suggestionId', + }; + + fixEntityCollection.batchGetByKeys = stub().resolves(mockFixEntities); + fixEntityCollection.idName = 'fixEntityId'; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + mockEntityRegistry.getCollection + .withArgs('SuggestionCollection') + .returns(mockSuggestionCollection); + + const result = await fixEntityCollection.getAllFixesWithSuggestionByCreatedAt( + opportunityId, + fixEntityCreatedDate, + ); + + expect(result).to.have.lengthOf(2); + expect(result[0].fixEntity.getId()).to.equal('fix-1'); + expect(result[0].suggestions).to.have.lengthOf(2); + expect(result[1].fixEntity.getId()).to.equal('fix-2'); + expect(result[1].suggestions).to.have.lengthOf(1); + + expect(mockFixEntitySuggestionCollection.allByOpportunityIdAndFixEntityCreatedDate) + .to.have.been.calledWith(opportunityId, fixEntityCreatedDate); + }); + + it('should handle empty results', async () => { + const opportunityId = '123e4567-e89b-12d3-a456-426614174001'; + const fixEntityCreatedDate = '2024-01-15'; + + const mockFixEntitySuggestionCollection = { + allByOpportunityIdAndFixEntityCreatedDate: stub().resolves([]), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection.getAllFixesWithSuggestionByCreatedAt( + opportunityId, + fixEntityCreatedDate, + ); + + expect(result).to.deep.equal([]); + }); + + it('should validate required parameters', async () => { + const opportunityId = '123e4567-e89b-12d3-a456-426614174001'; + const fixEntityCreatedDate = '2024-01-15'; + + // Test missing opportunityId + try { + await fixEntityCollection.getAllFixesWithSuggestionByCreatedAt(null, fixEntityCreatedDate); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error.message).to.include('opportunityId must be a valid UUID'); + } + + // Test missing fixEntityCreatedDate + try { + await fixEntityCollection.getAllFixesWithSuggestionByCreatedAt(opportunityId, null); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error.message).to.include('fixEntityCreatedDate is required'); + } + }); + + it('should handle errors gracefully', async () => { + const opportunityId = '123e4567-e89b-12d3-a456-426614174001'; + const fixEntityCreatedDate = '2024-01-15'; + + const mockFixEntitySuggestionCollection = { + allByOpportunityIdAndFixEntityCreatedDate: stub().rejects(new Error('Database error')), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); - expect(model).to.be.an('object'); + try { + await fixEntityCollection.getAllFixesWithSuggestionByCreatedAt( + opportunityId, + fixEntityCreatedDate, + ); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.be.instanceOf(DataAccessError); + expect(error.message).to.include('Failed to get all fixes with suggestions by created date'); + } }); }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/models/fix-entity/fix-entity.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/fix-entity/fix-entity.model.test.js new file mode 100644 index 000000000..0061cb5bd --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/fix-entity/fix-entity.model.test.js @@ -0,0 +1,116 @@ +/* + * Copyright 2025 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, restore } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import FixEntity from '../../../../src/models/fix-entity/fix-entity.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('FixEntityModel', () => { + let instance; + let mockEntityRegistry; + let mockRecord; + + beforeEach(() => { + mockRecord = { + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', + opportunityId: '123e4567-e89b-12d3-a456-426614174001', + type: 'SEO', + status: 'PENDING', + changeDetails: { field: 'title', oldValue: 'Old', newValue: 'New' }, + executedAt: '2024-01-01T00:00:00.000Z', + executedBy: 'user123', + publishedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + ({ + mockEntityRegistry, + model: instance, + } = createElectroMocks(FixEntity, mockRecord)); + }); + + afterEach(() => { + restore(); + }); + + describe('constructor', () => { + it('initializes the FixEntity instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('getSuggestions', () => { + it('should get suggestions for the fix entity', async () => { + const mockSuggestions = [ + { id: 'suggestion-1', title: 'Suggestion 1' }, + { id: 'suggestion-2', title: 'Suggestion 2' }, + ]; + + const mockFixEntityCollection = { + getSuggestionsByFixEntityId: stub().resolves(mockSuggestions), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntityCollection') + .returns(mockFixEntityCollection); + + const result = await instance.getSuggestions(); + + expect(result).to.deep.equal(mockSuggestions); + expect(mockFixEntityCollection.getSuggestionsByFixEntityId) + .to.have.been.calledOnceWith(instance.getId()); + }); + + it('should return empty array when no suggestions found', async () => { + const mockFixEntityCollection = { + getSuggestionsByFixEntityId: stub().resolves([]), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntityCollection') + .returns(mockFixEntityCollection); + + const result = await instance.getSuggestions(); + + expect(result).to.deep.equal([]); + expect(mockFixEntityCollection.getSuggestionsByFixEntityId) + .to.have.been.calledOnceWith(instance.getId()); + }); + + it('should propagate errors from collection method', async () => { + const error = new Error('Database error'); + const mockFixEntityCollection = { + getSuggestionsByFixEntityId: stub().rejects(error), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntityCollection') + .returns(mockFixEntityCollection); + + await expect(instance.getSuggestions()) + .to.be.rejectedWith('Database error'); + + expect(mockFixEntityCollection.getSuggestionsByFixEntityId) + .to.have.been.calledOnceWith(instance.getId()); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js index 75133f67e..caf68da99 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js @@ -80,15 +80,176 @@ describe('OpportunityModel', () => { describe('addFixEntities', () => { it('adds related fix entities to the opportunity', async () => { + const mockFixEntity = { + getId: stub().returns('fix-entity-1'), + getCreatedAt: stub().returns('2024-01-01T00:00:00Z'), + }; + const mockSuggestion = { + getId: stub().returns('suggestion-1'), + }; const mockFixEntityCollection = { - createMany: stub().returns(Promise.resolve({ id: 'fix-entity-1' })), + createMany: stub().returns(Promise.resolve({ + createdItems: [mockFixEntity], + errorItems: [], + })), + }; + const mockSuggestionCollection = { + batchGetByKeys: stub().returns(Promise.resolve({ + data: [mockSuggestion], + errors: [], + })), + idName: 'suggestionId', + }; + const mockFixEntitySuggestionCollection = { + createMany: stub().returns(Promise.resolve({ + createdItems: [], + errorItems: [], + })), }; mockEntityRegistry.getCollection.withArgs('FixEntityCollection').returns(mockFixEntityCollection); - - const fixEntity = await instance.addFixEntities([{ text: 'Fix entity text' }]); - expect(fixEntity).to.deep.equal({ id: 'fix-entity-1' }); + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(mockSuggestionCollection); + mockEntityRegistry.getCollection.withArgs('FixEntitySuggestionCollection').returns(mockFixEntitySuggestionCollection); + + const result = await instance.addFixEntities([{ + type: 'CODE_CHANGE', + changeDetails: { file: 'test.js' }, + suggestions: ['suggestion-1'], + }]); + expect(result.createdItems).to.have.lengthOf(1); + expect(result.createdItems[0]).to.equal(mockFixEntity); + expect(result.errorItems).to.have.lengthOf(0); expect(mockEntityRegistry.getCollection.calledWith('FixEntityCollection')).to.be.true; - expect(mockFixEntityCollection.createMany.calledOnceWith([{ text: 'Fix entity text', opportunityId: 'op12345' }])).to.be.true; + expect(mockFixEntityCollection.createMany.calledOnce).to.be.true; + expect(mockFixEntitySuggestionCollection.createMany.calledOnce).to.be.true; + }); + + it('adds invalid fixEntity to errorItems when fixEntity does not have suggestions', async () => { + const result = await instance.addFixEntities([{ text: 'Fix entity text' }]); + expect(result.createdItems).to.have.lengthOf(0); + expect(result.errorItems).to.have.lengthOf(1); + expect(result.errorItems[0].error.message).to.equal('fixEntity must have a suggestions property'); + }); + + it('adds invalid fixEntity to errorItems when suggestions is not an array', async () => { + const result = await instance.addFixEntities([{ + text: 'Fix entity text', + suggestions: 'not-an-array', + }]); + expect(result.createdItems).to.have.lengthOf(0); + expect(result.errorItems).to.have.lengthOf(1); + expect(result.errorItems[0].error.message).to.equal('fixEntity.suggestions must be an array'); + }); + + it('adds invalid fixEntity to errorItems when suggestions array is empty', async () => { + const result = await instance.addFixEntities([{ + text: 'Fix entity text', + suggestions: [], + }]); + expect(result.createdItems).to.have.lengthOf(0); + expect(result.errorItems).to.have.lengthOf(1); + expect(result.errorItems[0].error.message).to.equal('fixEntity.suggestions cannot be empty'); + }); + + it('adds invalid fixEntity to errorItems when suggestion IDs do not exist', async () => { + const mockSuggestionCollection = { + batchGetByKeys: stub().returns(Promise.resolve({ + data: [], + errors: [], + })), + idName: 'suggestionId', + }; + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(mockSuggestionCollection); + + const result = await instance.addFixEntities([{ + type: 'CODE_CHANGE', + changeDetails: { file: 'test.js' }, + suggestions: ['invalid-suggestion-id'], + }]); + expect(result.createdItems).to.have.lengthOf(0); + expect(result.errorItems).to.have.lengthOf(1); + expect(result.errorItems[0].error.message).to.include('Invalid suggestion IDs'); + }); + + it('handles errors when creating fix entity fails', async () => { + const mockSuggestion = { + getId: stub().returns('suggestion-1'), + }; + const mockFixEntityCollection = { + createMany: stub().returns(Promise.resolve({ + createdItems: [], + errorItems: [{ + item: { type: 'CODE_CHANGE', changeDetails: { file: 'test.js' }, opportunityId: 'op12345' }, + error: new Error('Creation failed'), + }], + })), + }; + const mockSuggestionCollection = { + batchGetByKeys: stub().returns(Promise.resolve({ + data: [mockSuggestion], + errors: [], + })), + idName: 'suggestionId', + }; + mockEntityRegistry.getCollection.withArgs('FixEntityCollection').returns(mockFixEntityCollection); + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(mockSuggestionCollection); + + const result = await instance.addFixEntities([{ + type: 'CODE_CHANGE', + changeDetails: { file: 'test.js' }, + suggestions: ['suggestion-1'], + }]); + expect(result.createdItems).to.have.lengthOf(0); + expect(result.errorItems).to.have.lengthOf(1); + expect(result.errorItems[0].error.message).to.equal('Creation failed'); + }); + + it('processes multiple fixEntities and categorizes them correctly', async () => { + const mockFixEntity = { + getId: stub().returns('fix-entity-1'), + getCreatedAt: stub().returns('2024-01-01T00:00:00Z'), + }; + const mockSuggestion = { + getId: stub().returns('suggestion-1'), + }; + const mockFixEntityCollection = { + createMany: stub().returns(Promise.resolve({ + createdItems: [mockFixEntity], + errorItems: [], + })), + }; + const mockSuggestionCollection = { + batchGetByKeys: stub().returns(Promise.resolve({ + data: [mockSuggestion], + errors: [], + })), + idName: 'suggestionId', + }; + const mockFixEntitySuggestionCollection = { + createMany: stub().returns(Promise.resolve({ + createdItems: [], + errorItems: [], + })), + }; + mockEntityRegistry.getCollection.withArgs('FixEntityCollection').returns(mockFixEntityCollection); + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(mockSuggestionCollection); + mockEntityRegistry.getCollection.withArgs('FixEntitySuggestionCollection').returns(mockFixEntitySuggestionCollection); + + const result = await instance.addFixEntities([ + { + type: 'CODE_CHANGE', + changeDetails: { file: 'test.js' }, + suggestions: ['suggestion-1'], + }, + { + text: 'Invalid - no suggestions', + }, + { + text: 'Invalid - empty suggestions', + suggestions: [], + }, + ]); + expect(result.createdItems).to.have.lengthOf(1); + expect(result.errorItems).to.have.lengthOf(2); }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/models/suggestion/suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/suggestion/suggestion.collection.test.js index 46ff2cc82..4a867d978 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/suggestion/suggestion.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/suggestion/suggestion.collection.test.js @@ -15,8 +15,10 @@ import { expect, use as chaiUse } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; +import { stub, restore } from 'sinon'; import Suggestion from '../../../../src/models/suggestion/suggestion.model.js'; +import DataAccessError from '../../../../src/errors/data-access.error.js'; import { createElectroMocks } from '../../util.js'; @@ -52,6 +54,10 @@ describe('SuggestionCollection', () => { } = createElectroMocks(Suggestion, mockRecord)); }); + afterEach(() => { + restore(); + }); + describe('constructor', () => { it('initializes the SuggestionCollection instance correctly', () => { expect(instance).to.be.an('object'); @@ -97,4 +103,91 @@ describe('SuggestionCollection', () => { .to.be.rejectedWith('Invalid status: foo. Must be one of: NEW, APPROVED, IN_PROGRESS, SKIPPED, FIXED, ERROR'); }); }); + + describe('getFixEntitiesBySuggestionId', () => { + it('should get fix entities for a suggestion', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const mockJunctionRecords = [ + { getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174003' }, + { getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174004' }, + ]; + const mockFixEntities = [ + { id: '123e4567-e89b-12d3-a456-426614174003', title: 'Fix 1' }, + { id: '123e4567-e89b-12d3-a456-426614174004', title: 'Fix 2' }, + ]; + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().resolves(mockJunctionRecords), + removeByIndexKeys: stub().resolves(), + }; + + const mockFixEntityCollection = { + batchGetByKeys: stub().resolves({ + data: mockFixEntities, + unprocessed: [], + }), + idName: 'fixEntityId', + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + mockEntityRegistry.getCollection + .withArgs('FixEntityCollection') + .returns(mockFixEntityCollection); + + const result = await instance.getFixEntitiesBySuggestionId(suggestionId); + + expect(result).to.deep.equal(mockFixEntities); + + expect(mockFixEntitySuggestionCollection.allBySuggestionId) + .to.have.been.calledOnceWith(suggestionId); + expect(mockFixEntityCollection.batchGetByKeys).to.have.been.calledOnceWith([ + { fixEntityId: '123e4567-e89b-12d3-a456-426614174003' }, + { fixEntityId: '123e4567-e89b-12d3-a456-426614174004' }, + ]); + }); + + it('should return empty arrays when no junction records found', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + const result = await instance.getFixEntitiesBySuggestionId(suggestionId); + + expect(result).to.deep.equal([]); + + expect(mockFixEntitySuggestionCollection.allBySuggestionId) + .to.have.been.calledOnceWith(suggestionId); + }); + + it('should throw error when suggestionId is not provided', async () => { + await expect(instance.getFixEntitiesBySuggestionId()) + .to.be.rejectedWith('Validation failed in SuggestionCollection: suggestionId must be a valid UUID'); + }); + + it('should handle errors and throw DataAccessError', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const error = new Error('Database error'); + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().rejects(error), + removeByIndexKeys: stub().resolves(), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + await expect(instance.getFixEntitiesBySuggestionId(suggestionId)) + .to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to get fix entities for suggestion', error); + }); + }); });