From d886eb0c67569384fd2fb36b24fc549629d7bfd1 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Tue, 23 Sep 2025 10:34:19 +0530 Subject: [PATCH 01/12] fix: theoretical approach 1 --- .../ELECTRODB-SCHEMAS.md | 52 + .../electrodb-schemas.json | 3115 +++++++++++++++++ .../src/models/base/entity.registry.js | 3 + .../fix-entity-suggestion.collection.js | 73 + .../fix-entity-suggestion.model.js | 29 + .../fix-entity-suggestion.schema.js | 29 + .../models/fix-entity-suggestion/index.d.ts | 29 + .../src/models/fix-entity-suggestion/index.js | 19 + .../models/fix-entity/fix-entity.schema.js | 2 +- .../src/models/fix-entity/index.d.ts | 5 +- .../src/models/index.d.ts | 1 + .../src/models/index.js | 1 + .../src/models/suggestion/index.d.ts | 5 +- .../models/suggestion/suggestion.schema.js | 2 +- 14 files changed, 3357 insertions(+), 8 deletions(-) create mode 100644 packages/spacecat-shared-data-access/ELECTRODB-SCHEMAS.md create mode 100644 packages/spacecat-shared-data-access/electrodb-schemas.json create mode 100644 packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.collection.js create mode 100644 packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.model.js create mode 100644 packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js create mode 100644 packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/index.js diff --git a/packages/spacecat-shared-data-access/ELECTRODB-SCHEMAS.md b/packages/spacecat-shared-data-access/ELECTRODB-SCHEMAS.md new file mode 100644 index 000000000..b5c91d50e --- /dev/null +++ b/packages/spacecat-shared-data-access/ELECTRODB-SCHEMAS.md @@ -0,0 +1,52 @@ +# ElectroDB Entity Schemas + +Generated on: 2025-09-23T05:00:03.371Z +Total Entities: 27 + +## Many-to-Many Relationship Implementation + +This schema includes the implementation of a many-to-many relationship between `FixEntity` and `Suggestion` entities through the `FixEntitySuggestion` junction table. + +### Key Changes: +- **FixEntity**: Now connects to Suggestions via FixEntitySuggestion junction table +- **Suggestion**: Now connects to FixEntities via FixEntitySuggestion junction table +- **FixEntitySuggestion**: New junction entity enabling many-to-many relationships + +### Relationship Flow: +``` +FixEntity ←→ FixEntitySuggestion ←→ Suggestion +``` + +## Entity List + +- `apiKey` +- `asyncJob` +- `audit` +- `configuration` +- `entitlement` +- `experiment` +- `fixEntity` +- `fixEntitySuggestion` +- `importJob` +- `importUrl` +- `keyEvent` +- `latestAudit` +- `opportunity` +- `organization` +- `organizationIdentityProvider` +- `pageIntent` +- `report` +- `scrapeJob` +- `scrapeUrl` +- `site` +- `siteCandidate` +- `siteEnrollment` +- `siteTopForm` +- `siteTopPage` +- `suggestion` +- `trialUser` +- `trialUserActivity` + +## Schema Details + +See the complete schemas in `electrodb-schemas.json`. diff --git a/packages/spacecat-shared-data-access/electrodb-schemas.json b/packages/spacecat-shared-data-access/electrodb-schemas.json new file mode 100644 index 000000000..e97f8bbdb --- /dev/null +++ b/packages/spacecat-shared-data-access/electrodb-schemas.json @@ -0,0 +1,3115 @@ +{ + "metadata": { + "generatedAt": "2025-09-23T05:02:59.079Z", + "totalEntities": 27, + "description": "Complete ElectroDB schemas for all SpaceCat entities including the corrected many-to-many relationship between FixEntity and Suggestion via FixEntitySuggestion junction table (fixed duplicate index issue)" + }, + "entities": { + "apiKey": { + "model": { + "entity": "ApiKey", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "apiKeyId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "hashedApiKey": { + "type": "string", + "required": true + }, + "imsUserId": { + "type": "string", + "default": "default" + }, + "imsOrgId": { + "type": "string", + "default": "default" + }, + "name": { + "type": "string", + "required": true + }, + "deletedAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "revokedAt": { + "type": "string" + }, + "scopes": { + "type": "list", + "required": true, + "items": { + "type": "map", + "properties": { + "actions": { + "type": "list", + "items": { + "type": "string" + } + }, + "domains": { + "type": "list", + "items": { + "type": "string" + } + }, + "name": { + "type": [ + "sites.read_all", + "sites.write_all", + "organizations.read_all", + "organizations.write_all", + "audits.read_all", + "audits.write_all", + "imports.read", + "imports.write", + "imports.delete", + "imports.read_all", + "imports.all_domains", + "imports.assistant" + ] + } + } + } + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "apiKeyId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "other", + "pk": { + "field": "gsi1pk", + "composite": [ + "hashedApiKey" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "other", + "pk": { + "field": "gsi2pk", + "composite": [ + "imsOrgId", + "imsUserId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "asyncJob": { + "model": { + "entity": "AsyncJob", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "asyncJobId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "recordExpiresAt": { + "type": "number", + "required": true, + "readOnly": true + }, + "status": { + "type": [ + "IN_PROGRESS", + "COMPLETED", + "FAILED", + "CANCELLED" + ], + "required": true + }, + "resultLocation": { + "type": "string" + }, + "resultType": { + "type": "string" + }, + "result": { + "type": "any" + }, + "error": { + "type": "map", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "any" + } + } + }, + "metadata": { + "type": "any" + }, + "startedAt": { + "type": "string", + "required": true, + "readOnly": true + }, + "endedAt": { + "type": "string" + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "asyncJobId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "other", + "pk": { + "field": "gsi1pk", + "composite": [ + "status" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "audit": { + "model": { + "entity": "Audit", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "auditId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "siteId": { + "type": "string", + "required": true + }, + "auditResult": { + "type": "any", + "required": true + }, + "auditType": { + "type": "string", + "required": true + }, + "fullAuditRef": { + "type": "string", + "required": true + }, + "isLive": { + "type": "boolean", + "required": true, + "default": false + }, + "isError": { + "type": "boolean", + "required": true, + "default": false + }, + "auditedAt": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "auditId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "auditType", + "auditedAt" + ] + } + } + } + }, + "configuration": { + "model": { + "entity": "Configuration", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "configurationId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "handlers": { + "type": "any" + }, + "jobs": { + "type": "list", + "items": { + "type": "map", + "properties": { + "group": { + "type": [ + "audits", + "imports", + "reports", + "scrapes" + ], + "required": true + }, + "type": { + "type": "string", + "required": true + }, + "interval": { + "type": [ + "never", + "every-hour", + "daily", + "weekly", + "every-saturday", + "every-sunday", + "fortnightly", + "fortnightly-saturday", + "fortnightly-sunday", + "monthly" + ], + "required": true + } + } + } + }, + "queues": { + "type": "any", + "required": true + }, + "slackRoles": { + "type": "any" + }, + "version": { + "type": "number", + "required": true, + "readOnly": true + }, + "versionString": { + "type": "string", + "required": true, + "readOnly": true, + "default": "0" + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "configurationId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "all", + "pk": { + "field": "gsi1pk", + "template": "ALL_CONFIGURATIONS" + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "versionString" + ] + } + } + } + }, + "entitlement": { + "model": { + "entity": "Entitlement", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "entitlementId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "organizationId": { + "type": "string", + "required": true + }, + "productCode": { + "type": [ + "LLMO", + "ASO" + ], + "required": true + }, + "tier": { + "type": [ + "FREE_TRIAL", + "PAID" + ], + "required": true + }, + "quotas": { + "type": "map", + "required": false, + "properties": { + "llmo_trial_prompts": { + "type": "number" + }, + "llmo_trial_prompts_consumed": { + "type": "number" + } + } + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "entitlementId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "organizationId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "other", + "pk": { + "field": "gsi2pk", + "composite": [ + "organizationId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "productCode" + ] + } + } + } + }, + "experiment": { + "model": { + "entity": "Experiment", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "experimentId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": true, + "default": "spacecat" + }, + "siteId": { + "type": "string", + "required": true + }, + "conversionEventName": { + "type": "string" + }, + "conversionEventValue": { + "type": "string" + }, + "endDate": { + "type": "string" + }, + "expId": { + "type": "string", + "required": true + }, + "name": { + "type": "string" + }, + "startDate": { + "type": "string" + }, + "status": { + "type": [ + "ACTIVE", + "INACTIVE" + ], + "required": true + }, + "type": { + "type": "string" + }, + "url": { + "type": "string", + "required": true + }, + "variants": { + "type": "list", + "items": { + "type": "any" + }, + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "experimentId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "expId", + "url", + "updatedAt" + ] + } + } + } + }, + "fixEntity": { + "model": { + "entity": "FixEntity", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "fixEntityId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "opportunityId": { + "type": "string", + "required": true + }, + "type": { + "type": [ + "CODE_CHANGE", + "CONTENT_UPDATE", + "REDIRECT_UPDATE", + "METADATA_UPDATE", + "AI_INSIGHTS" + ], + "required": true, + "readOnly": true + }, + "executedBy": { + "type": "string" + }, + "executedAt": { + "type": "string" + }, + "publishedAt": { + "type": "string" + }, + "changeDetails": { + "type": "any", + "required": true + }, + "status": { + "type": [ + "PENDING", + "DEPLOYED", + "PUBLISHED", + "FAILED", + "ROLLED_BACK" + ], + "required": true, + "default": "PENDING" + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "fixEntityId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "opportunityId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "status" + ] + } + } + } + }, + "fixEntitySuggestion": { + "model": { + "entity": "FixEntitySuggestion", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "fixEntitySuggestionId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "fixEntityId": { + "type": "string", + "required": true + }, + "suggestionId": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "fixEntitySuggestionId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "fixEntityId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi2pk", + "composite": [ + "suggestionId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "importJob": { + "model": { + "entity": "ImportJob", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "importJobId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "baseURL": { + "type": "string", + "required": true + }, + "duration": { + "type": "number", + "default": 0 + }, + "endedAt": { + "type": "string" + }, + "failedCount": { + "type": "number", + "default": 0 + }, + "hasCustomHeaders": { + "type": "boolean", + "default": false + }, + "hasCustomImportJs": { + "type": "boolean", + "default": false + }, + "hashedApiKey": { + "type": "string", + "required": true + }, + "importQueueId": { + "type": "string" + }, + "initiatedBy": { + "type": "map", + "properties": { + "apiKeyName": { + "type": "string" + }, + "imsOrgId": { + "type": "string" + }, + "imsUserId": { + "type": "string" + }, + "userAgent": { + "type": "string" + } + } + }, + "options": { + "type": "any" + }, + "redirectCount": { + "type": "number", + "default": 0 + }, + "status": { + "type": [ + "RUNNING", + "COMPLETE", + "FAILED", + "STOPPED" + ], + "required": true + }, + "startedAt": { + "type": "string", + "required": true, + "readOnly": true + }, + "successCount": { + "type": "number", + "default": 0 + }, + "urlCount": { + "type": "number", + "default": 0 + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "importJobId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "all", + "pk": { + "field": "gsi1pk", + "template": "ALL_IMPORTJOBS" + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "startedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "other", + "pk": { + "field": "gsi2pk", + "composite": [ + "status" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "importUrl": { + "model": { + "entity": "ImportUrl", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "importUrlId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "recordExpiresAt": { + "type": "number", + "required": true, + "readOnly": true + }, + "importJobId": { + "type": "string", + "required": true + }, + "file": { + "type": "string" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": [ + "PENDING", + "REDIRECT", + "RUNNING", + "COMPLETE", + "FAILED", + "STOPPED" + ], + "required": true + }, + "url": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "importUrlId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "importJobId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "status" + ] + } + } + } + }, + "keyEvent": { + "model": { + "entity": "KeyEvent", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "keyEventId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "siteId": { + "type": "string", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "type": { + "type": [ + "PERFORMANCE", + "SEO", + "CONTENT", + "CODE", + "THIRD PARTY", + "EXPERIMENTATION", + "NETWORK", + "STATUS CHANGE" + ], + "required": true + }, + "time": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "keyEventId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "time" + ] + } + } + } + }, + "latestAudit": { + "model": { + "entity": "LatestAudit", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "latestAuditId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "siteId": { + "type": "string", + "required": true + }, + "auditId": { + "type": "string", + "required": true + }, + "auditResult": { + "type": "any", + "required": true + }, + "auditType": { + "type": "string", + "required": true + }, + "fullAuditRef": { + "type": "string", + "required": true + }, + "isLive": { + "type": "boolean", + "required": true, + "default": false + }, + "isError": { + "type": "boolean", + "required": true, + "default": false + }, + "auditedAt": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "sk", + "composite": [ + "auditType" + ] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "all", + "pk": { + "field": "gsi1pk", + "template": "ALL_LATESTAUDITS" + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "auditType" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi2pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "auditType" + ] + } + }, + "spacecat-data-gsi3pk-gsi3sk": { + "index": "spacecat-data-gsi3pk-gsi3sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi3pk", + "composite": [ + "auditId" + ] + }, + "sk": { + "field": "gsi3sk", + "composite": [ + "auditType" + ] + } + } + } + }, + "opportunity": { + "model": { + "entity": "Opportunity", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "opportunityId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "siteId": { + "type": "string", + "required": true + }, + "auditId": { + "type": "string", + "required": false + }, + "latestAuditId": { + "type": "string", + "required": false + }, + "runbook": { + "type": "string" + }, + "type": { + "type": "string", + "readOnly": true, + "required": true + }, + "data": { + "type": "any" + }, + "origin": { + "type": [ + "ESS_OPS", + "AI", + "AUTOMATION" + ], + "required": true + }, + "title": { + "type": "string", + "required": true + }, + "description": { + "type": "string" + }, + "status": { + "type": [ + "NEW", + "IN_PROGRESS", + "IGNORED", + "RESOLVED" + ], + "required": true, + "default": "NEW" + }, + "guidance": { + "type": "any" + }, + "tags": { + "type": "set", + "items": "string" + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "opportunityId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "status", + "updatedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi2pk", + "composite": [ + "auditId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi3pk-gsi3sk": { + "index": "spacecat-data-gsi3pk-gsi3sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi3pk", + "composite": [ + "latestAuditId" + ] + }, + "sk": { + "field": "gsi3sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "organization": { + "model": { + "entity": "Organization", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "organizationId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "config": { + "type": "any", + "required": true, + "default": { + "slack": {}, + "handlers": {} + } + }, + "name": { + "type": "string", + "required": true + }, + "imsOrgId": { + "type": "string" + }, + "fulfillableItems": { + "type": "any" + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "organizationId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "all", + "pk": { + "field": "gsi1pk", + "template": "ALL_ORGANIZATIONS" + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "imsOrgId" + ] + } + } + } + }, + "organizationIdentityProvider": { + "model": { + "entity": "OrganizationIdentityProvider", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "organizationIdentityProviderId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "organizationId": { + "type": "string", + "required": true + }, + "metadata": { + "type": "any", + "required": false + }, + "provider": { + "type": [ + "IMS", + "MICROSOFT", + "GOOGLE" + ], + "required": true + }, + "externalId": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "organizationIdentityProviderId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "organizationId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "other", + "pk": { + "field": "gsi2pk", + "composite": [ + "provider" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "externalId" + ] + } + } + } + }, + "pageIntent": { + "model": { + "entity": "PageIntent", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "pageIntentId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "default": "spacecat" + }, + "siteId": { + "type": "string", + "required": true + }, + "url": { + "type": "string", + "required": true + }, + "pageIntent": { + "type": [ + "INFORMATIONAL", + "NAVIGATIONAL", + "TRANSACTIONAL", + "COMMERCIAL" + ], + "required": true + }, + "topic": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "pageIntentId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "other", + "pk": { + "field": "gsi2pk", + "composite": [ + "url" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "report": { + "model": { + "entity": "Report", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "reportId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "siteId": { + "type": "string", + "required": true + }, + "reportType": { + "type": "string", + "required": true + }, + "reportPeriod": { + "type": "any", + "required": true + }, + "comparisonPeriod": { + "type": "any", + "required": true + }, + "storagePath": { + "type": "string", + "required": false + }, + "status": { + "type": [ + "processing", + "success", + "failed" + ], + "required": true, + "default": "processing" + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "reportId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "all", + "pk": { + "field": "gsi1pk", + "template": "ALL_REPORTS" + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "reportType" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi2pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "scrapeJob": { + "model": { + "entity": "ScrapeJob", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "scrapeJobId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "recordExpiresAt": { + "type": "number", + "required": true, + "readOnly": true + }, + "baseURL": { + "type": "string", + "required": true + }, + "processingType": { + "type": "string", + "required": true + }, + "duration": { + "type": "number", + "default": 0 + }, + "endedAt": { + "type": "string" + }, + "failedCount": { + "type": "number", + "default": 0 + }, + "scrapeQueueId": { + "type": "string" + }, + "options": { + "type": "any" + }, + "customHeaders": { + "type": "any" + }, + "redirectCount": { + "type": "number", + "default": 0 + }, + "status": { + "type": [ + "RUNNING", + "COMPLETE", + "FAILED", + "STOPPED" + ], + "required": true + }, + "startedAt": { + "type": "string", + "required": true, + "readOnly": true + }, + "successCount": { + "type": "number", + "default": 0 + }, + "urlCount": { + "type": "number", + "default": 0 + }, + "results": { + "type": "any" + }, + "optEnableJavascript": { + "type": "string", + "hidden": true, + "readOnly": true, + "watch": [ + "options" + ] + }, + "optHideConsentBanner": { + "type": "string", + "hidden": true, + "readOnly": true, + "watch": [ + "options" + ] + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "scrapeJobId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "all", + "pk": { + "field": "gsi1pk", + "template": "ALL_SCRAPEJOBS" + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "startedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "other", + "pk": { + "field": "gsi2pk", + "composite": [ + "baseURL" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "processingType", + "startedAt" + ] + } + }, + "spacecat-data-gsi3pk-gsi3sk": { + "index": "spacecat-data-gsi3pk-gsi3sk", + "indexType": "other", + "pk": { + "field": "gsi3pk", + "composite": [ + "baseURL", + "processingType" + ] + }, + "sk": { + "field": "gsi3sk", + "composite": [ + "optEnableJavascript", + "optHideConsentBanner", + "startedAt" + ] + } + }, + "spacecat-data-gsi4pk-gsi4sk": { + "index": "spacecat-data-gsi4pk-gsi4sk", + "indexType": "other", + "pk": { + "field": "gsi4pk", + "composite": [ + "status" + ] + }, + "sk": { + "field": "gsi4sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "scrapeUrl": { + "model": { + "entity": "ScrapeUrl", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "scrapeUrlId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "recordExpiresAt": { + "type": "number", + "required": true, + "readOnly": true + }, + "scrapeJobId": { + "type": "string", + "required": true + }, + "file": { + "type": "string" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": [ + "PENDING", + "REDIRECT", + "RUNNING", + "COMPLETE", + "FAILED", + "STOPPED" + ], + "required": true + }, + "url": { + "type": "string", + "required": true + }, + "isOriginal": { + "type": "boolean", + "default": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "scrapeUrlId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "scrapeJobId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "status" + ] + } + } + } + }, + "site": { + "model": { + "entity": "Site", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "siteId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "organizationId": { + "type": "string", + "required": true + }, + "baseURL": { + "type": "string", + "required": true + }, + "name": { + "type": "string" + }, + "config": { + "type": "any", + "required": true, + "default": { + "slack": {}, + "handlers": {} + } + }, + "deliveryType": { + "type": [ + "aem_cs", + "aem_edge", + "aem_ams", + "aem_headless", + "other" + ], + "default": "aem_edge", + "required": true + }, + "authoringType": { + "type": [ + "cs/crosswalk", + "cs", + "sharepoint", + "googledocs", + "documentauthoring" + ], + "required": false + }, + "gitHubURL": { + "type": "string" + }, + "deliveryConfig": { + "type": "any", + "default": {}, + "properties": { + "programId": { + "type": "string" + }, + "environmentId": { + "type": "string" + }, + "authorURL": { + "type": "string" + }, + "siteId": { + "type": "string" + } + } + }, + "hlxConfig": { + "type": "any", + "default": {} + }, + "isSandbox": { + "type": "boolean", + "default": false + }, + "isLive": { + "type": "boolean", + "required": true, + "default": false + }, + "isLiveToggledAt": { + "type": "string", + "watch": [ + "isLive" + ] + }, + "externalOwnerId": { + "type": "string", + "hidden": true, + "readOnly": true, + "watch": [ + "authoringType", + "hlxConfig", + "deliveryConfig" + ] + }, + "externalSiteId": { + "type": "string", + "hidden": true, + "readOnly": true, + "watch": [ + "authoringType", + "hlxConfig", + "deliveryConfig" + ] + }, + "pageTypes": { + "type": "list", + "required": false, + "items": { + "type": "map", + "required": true, + "properties": { + "name": { + "type": "string", + "required": true + }, + "pattern": { + "type": "string", + "required": true + } + } + } + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "all", + "pk": { + "field": "gsi1pk", + "template": "ALL_SITES" + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "baseURL" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi2pk", + "composite": [ + "organizationId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi3pk-gsi3sk": { + "index": "spacecat-data-gsi3pk-gsi3sk", + "indexType": "other", + "pk": { + "field": "gsi3pk", + "composite": [ + "deliveryType" + ] + }, + "sk": { + "field": "gsi3sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi4pk-gsi4sk": { + "index": "spacecat-data-gsi4pk-gsi4sk", + "indexType": "other", + "pk": { + "field": "gsi4pk", + "composite": [ + "externalOwnerId" + ] + }, + "sk": { + "field": "gsi4sk", + "composite": [ + "externalSiteId" + ] + } + } + } + }, + "siteCandidate": { + "model": { + "entity": "SiteCandidate", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "siteCandidateId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string" + }, + "siteId": { + "type": "string" + }, + "baseURL": { + "type": "string", + "required": true + }, + "hlxConfig": { + "type": "any", + "required": true, + "default": {} + }, + "source": { + "type": [ + "SPACECAT_SLACK_BOT", + "RUM", + "CDN" + ], + "required": true + }, + "status": { + "type": [ + "PENDING", + "IGNORED", + "APPROVED", + "ERROR" + ], + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "siteCandidateId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "all", + "pk": { + "field": "gsi1pk", + "template": "ALL_SITECANDIDATES" + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "baseURL" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi2pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "siteEnrollment": { + "model": { + "entity": "SiteEnrollment", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "siteEnrollmentId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "siteId": { + "type": "string", + "required": true + }, + "entitlementId": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "siteEnrollmentId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi2pk", + "composite": [ + "entitlementId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + } + } + }, + "siteTopForm": { + "model": { + "entity": "SiteTopForm", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "siteTopFormId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "siteId": { + "type": "string", + "required": true + }, + "url": { + "type": "string", + "required": true + }, + "formSource": { + "type": "string", + "required": false, + "default": "" + }, + "traffic": { + "type": "number", + "required": false, + "default": 0 + }, + "source": { + "type": "string", + "required": true + }, + "importedAt": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "siteTopFormId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "source", + "traffic" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "other", + "pk": { + "field": "gsi2pk", + "composite": [ + "url", + "formSource" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "traffic" + ] + } + } + } + }, + "siteTopPage": { + "model": { + "entity": "SiteTopPage", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "siteTopPageId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "siteId": { + "type": "string", + "required": true + }, + "url": { + "type": "string", + "required": true + }, + "traffic": { + "type": "number", + "required": true + }, + "source": { + "type": "string", + "required": true + }, + "topKeyword": { + "type": "string" + }, + "geo": { + "type": "string", + "required": false, + "default": "global" + }, + "importedAt": { + "type": "string", + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "siteTopPageId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "source", + "geo", + "traffic" + ] + } + } + } + }, + "suggestion": { + "model": { + "entity": "Suggestion", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "suggestionId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "opportunityId": { + "type": "string", + "required": true + }, + "type": { + "type": [ + "CODE_CHANGE", + "CONTENT_UPDATE", + "REDIRECT_UPDATE", + "METADATA_UPDATE", + "AI_INSIGHTS" + ], + "required": true, + "readOnly": true + }, + "rank": { + "type": "number", + "required": true + }, + "data": { + "type": "any", + "required": true + }, + "kpiDeltas": { + "type": "any" + }, + "status": { + "type": [ + "NEW", + "APPROVED", + "IN_PROGRESS", + "SKIPPED", + "FIXED", + "ERROR", + "OUTDATED" + ], + "required": true, + "default": "NEW" + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "suggestionId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "opportunityId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "status", + "rank" + ] + } + } + } + }, + "trialUser": { + "model": { + "entity": "TrialUser", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "trialUserId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "organizationId": { + "type": "string", + "required": true + }, + "externalUserId": { + "type": "string", + "required": false + }, + "status": { + "type": [ + "INVITED", + "REGISTERED", + "BLOCKED", + "DELETED" + ], + "required": true + }, + "provider": { + "type": [ + "IMS", + "MICROSOFT", + "GOOGLE" + ], + "required": false + }, + "lastSeenAt": { + "type": "string" + }, + "emailId": { + "type": "string", + "required": true + }, + "firstName": { + "type": "string", + "required": false + }, + "lastName": { + "type": "string", + "required": false + }, + "metadata": { + "type": "any" + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "trialUserId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "all", + "pk": { + "field": "gsi1pk", + "template": "ALL_TRIALUSERS" + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "emailId" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi2pk", + "composite": [ + "organizationId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi3pk-gsi3sk": { + "index": "spacecat-data-gsi3pk-gsi3sk", + "indexType": "other", + "pk": { + "field": "gsi3pk", + "composite": [ + "provider" + ] + }, + "sk": { + "field": "gsi3sk", + "composite": [ + "externalUserId" + ] + } + } + } + }, + "trialUserActivity": { + "model": { + "entity": "TrialUserActivity", + "version": "1", + "service": "SpaceCat" + }, + "attributes": { + "trialUserActivityId": { + "type": "string", + "required": true, + "readOnly": true + }, + "createdAt": { + "type": "string", + "readOnly": true, + "required": true + }, + "updatedAt": { + "type": "string", + "required": true, + "readOnly": true, + "watch": "*" + }, + "updatedBy": { + "type": "string", + "required": false, + "readOnly": false, + "watch": "*" + }, + "trialUserId": { + "type": "string", + "required": true + }, + "entitlementId": { + "type": "string", + "required": true + }, + "siteId": { + "type": "string", + "required": true + }, + "type": { + "type": [ + "SIGN_UP", + "SIGN_IN", + "CREATE_SITE", + "RUN_AUDIT", + "PROMPT_RUN", + "DOWNLOAD" + ], + "required": true + }, + "details": { + "type": "any" + }, + "productCode": { + "type": [ + "LLMO", + "ASO" + ], + "required": true + } + }, + "indexes": { + "primary": { + "pk": { + "field": "pk", + "composite": [ + "trialUserActivityId" + ] + }, + "sk": { + "field": "sk", + "composite": [] + } + }, + "spacecat-data-gsi1pk-gsi1sk": { + "index": "spacecat-data-gsi1pk-gsi1sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi1pk", + "composite": [ + "trialUserId" + ] + }, + "sk": { + "field": "gsi1sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi2pk-gsi2sk": { + "index": "spacecat-data-gsi2pk-gsi2sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi2pk", + "composite": [ + "entitlementId" + ] + }, + "sk": { + "field": "gsi2sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi3pk-gsi3sk": { + "index": "spacecat-data-gsi3pk-gsi3sk", + "indexType": "belongs_to", + "pk": { + "field": "gsi3pk", + "composite": [ + "siteId" + ] + }, + "sk": { + "field": "gsi3sk", + "composite": [ + "updatedAt" + ] + } + }, + "spacecat-data-gsi4pk-gsi4sk": { + "index": "spacecat-data-gsi4pk-gsi4sk", + "indexType": "other", + "pk": { + "field": "gsi4pk", + "composite": [ + "productCode" + ] + }, + "sk": { + "field": "gsi4sk", + "composite": [ + "createdAt" + ] + } + } + } + } + } +} \ No newline at end of file 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 f5c914453..c9aafbcb4 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'; @@ -154,6 +156,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/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..188f26cfe --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.collection.js @@ -0,0 +1,73 @@ +/* + * 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 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. + * + * @class FixEntitySuggestionCollection + * @extends BaseCollection + */ +class FixEntitySuggestionCollection extends BaseCollection { + /** + * Find all suggestions for a given fix entity + * @param {string} fixEntityId - The ID of the fix entity + * @returns {Promise} Array of junction records + */ + async allByFixEntityId(fixEntityId) { + return this.allByForeignKey('fixEntityId', fixEntityId); + } + + /** + * Find all fix entities for a given suggestion + * @param {string} suggestionId - The ID of the suggestion + * @returns {Promise} Array of junction records + */ + async allBySuggestionId(suggestionId) { + return this.allByForeignKey('suggestionId', suggestionId); + } + + /** + * Create a relationship between a fix entity and suggestion + * @param {string} fixEntityId - The ID of the fix entity + * @param {string} suggestionId - The ID of the suggestion + * @returns {Promise} The created junction record + */ + async createRelationship(fixEntityId, suggestionId) { + return this.create({ + fixEntityId, + suggestionId, + }); + } + + /** + * Remove a relationship between a fix entity and suggestion + * @param {string} fixEntityId - The ID of the fix entity + * @param {string} suggestionId - The ID of the suggestion + * @returns {Promise} + */ + async removeRelationship(fixEntityId, suggestionId) { + const relationships = await this.entity + .query.fixEntityId({ fixEntityId }) + .where(({ suggestionId: sid }, { eq }) => eq(sid, suggestionId)) + .go(); + + if (relationships.data && relationships.data.length > 0) { + await this.remove(relationships.data[0].id); + } + } +} + +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..579f5788e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.model.js @@ -0,0 +1,29 @@ +/* + * 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'; + + // Add custom methods here for junction-specific functionality +} + +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..3da6507e7 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js @@ -0,0 +1,29 @@ +/* + * 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) + // Reference to FixEntity (many-to-one relationship from junction table) + .addReference('belongs_to', 'FixEntity') + // Reference to Suggestion (many-to-one relationship from junction table) + .addReference('belongs_to', 'Suggestion'); + +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..296d007a7 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/index.d.ts @@ -0,0 +1,29 @@ +/* + * 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; + getFixEntityId(): string; + setFixEntityId(value: string): this; + getSuggestion(): Promise; + getSuggestionId(): string; + setSuggestionId(value: string): this; +} + +export interface FixEntitySuggestionCollection extends BaseCollection { + allByFixEntityId(fixEntityId: string): Promise; + allBySuggestionId(suggestionId: string): Promise; + createRelationship(fixEntityId: string, suggestionId: string): Promise; + removeRelationship(fixEntityId: string, suggestionId: 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.schema.js b/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.schema.js index 1d66d1bcd..77b1a1e36 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 @@ -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') .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..64cd893fd 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, FixEntitySuggestion, } from '../index'; export interface FixEntity extends BaseModel { @@ -28,8 +28,7 @@ export interface FixEntity extends BaseModel { setPublishedAt(value: string): this; getStatus(): string; setStatus(value: string): this; - getSuggestions(): Promise; - getSuggestionsByUpdatedAt(updatedAt: string): Promise; + getFixEntitySuggestions(): Promise; getType(): string; } 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 4d1152004..77725915d 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 d9f77d393..b32576a92 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/suggestion/index.d.ts b/packages/spacecat-shared-data-access/src/models/suggestion/index.d.ts index 49b6619e3..c4c17e9a4 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,18 +10,17 @@ * governing permissions and limitations under the License. */ -import type { BaseCollection, BaseModel, Opportunity } from '../index'; +import type { BaseCollection, BaseModel, Opportunity, FixEntitySuggestion } 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; + getFixEntitySuggestions(): Promise; setData(data: object): Suggestion; setKpiDeltas(kpiDeltas: object): Suggestion; setOpportunityId(opportunityId: string): Suggestion; 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..74f7ca0f0 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') .addAttribute('type', { type: Object.values(Suggestion.TYPES), required: true, From 55b08148510fc91dd95daac63b35542da5789b83 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Wed, 1 Oct 2025 11:50:20 +0530 Subject: [PATCH 02/12] fix: update keys --- .../electrodb-schemas.json | 30 +-- .../fix-entity-suggestion.collection.js | 48 +++- .../fix-entity-suggestion.schema.js | 13 +- .../models/fix-entity-suggestion/index.d.ts | 6 +- .../fix-entity-suggestion.collection.test.js | 244 ++++++++++++++++++ .../fix-entity-suggestion.model.test.js | 46 ++++ .../spacecat-shared-html-analyzer/xunit.xml | 40 +++ 7 files changed, 387 insertions(+), 40 deletions(-) create mode 100644 packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.collection.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.model.test.js create mode 100644 packages/spacecat-shared-html-analyzer/xunit.xml diff --git a/packages/spacecat-shared-data-access/electrodb-schemas.json b/packages/spacecat-shared-data-access/electrodb-schemas.json index e97f8bbdb..b126d16e0 100644 --- a/packages/spacecat-shared-data-access/electrodb-schemas.json +++ b/packages/spacecat-shared-data-access/electrodb-schemas.json @@ -1,8 +1,8 @@ { "metadata": { - "generatedAt": "2025-09-23T05:02:59.079Z", + "generatedAt": "2025-09-23T05:08:20.005Z", "totalEntities": 27, - "description": "Complete ElectroDB schemas for all SpaceCat entities including the corrected many-to-many relationship between FixEntity and Suggestion via FixEntitySuggestion junction table (fixed duplicate index issue)" + "description": "Optimized ElectroDB schemas with efficient index usage for FixEntitySuggestion junction table" }, "entities": { "apiKey": { @@ -804,11 +804,11 @@ "readOnly": false, "watch": "*" }, - "fixEntityId": { + "suggestionId": { "type": "string", "required": true }, - "suggestionId": { + "fixEntityId": { "type": "string", "required": true } @@ -818,12 +818,14 @@ "pk": { "field": "pk", "composite": [ - "fixEntitySuggestionId" + "suggestionId" ] }, "sk": { "field": "sk", - "composite": [] + "composite": [ + "fixEntityId" + ] } }, "spacecat-data-gsi1pk-gsi1sk": { @@ -841,22 +843,6 @@ "updatedAt" ] } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi2pk", - "composite": [ - "suggestionId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } } } }, 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 index 188f26cfe..1f5240913 100644 --- 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 @@ -31,12 +31,18 @@ class FixEntitySuggestionCollection extends BaseCollection { } /** - * Find all fix entities for a given suggestion + * Find all fix entities for a given suggestion using primary index * @param {string} suggestionId - The ID of the suggestion * @returns {Promise} Array of junction records */ async allBySuggestionId(suggestionId) { - return this.allByForeignKey('suggestionId', suggestionId); + try { + const result = await this.entity.query.primary({ suggestionId }).go(); + return result.data.map((item) => this.createInstance(item)); + } catch (error) { + this.log.error(`Failed to query FixEntitySuggestions by suggestionId: ${error.message}`); + throw error; + } } /** @@ -52,20 +58,38 @@ class FixEntitySuggestionCollection extends BaseCollection { }); } + /** + * Find a specific relationship between a fix entity and suggestion + * @param {string} suggestionId - The ID of the suggestion (PK) + * @param {string} fixEntityId - The ID of the fix entity (SK) + * @returns {Promise} The junction record or null + */ + async findRelationship(suggestionId, fixEntityId) { + try { + const result = await this.entity.get({ suggestionId, fixEntityId }).go(); + return this.createInstance(result.data); + } catch (error) { + if (error.message?.includes('not found')) { + return null; + } + throw error; + } + } + /** * Remove a relationship between a fix entity and suggestion - * @param {string} fixEntityId - The ID of the fix entity - * @param {string} suggestionId - The ID of the suggestion + * @param {string} suggestionId - The ID of the suggestion (PK) + * @param {string} fixEntityId - The ID of the fix entity (SK) * @returns {Promise} */ - async removeRelationship(fixEntityId, suggestionId) { - const relationships = await this.entity - .query.fixEntityId({ fixEntityId }) - .where(({ suggestionId: sid }, { eq }) => eq(sid, suggestionId)) - .go(); - - if (relationships.data && relationships.data.length > 0) { - await this.remove(relationships.data[0].id); + async removeRelationship(suggestionId, fixEntityId) { + try { + await this.entity.delete({ suggestionId, fixEntityId }).go(); + } catch (error) { + // Ignore "not found" errors since the goal is to ensure the relationship doesn't exist + if (!error.message?.includes('not found')) { + throw error; + } } } } 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 index 3da6507e7..adc5c5705 100644 --- 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 @@ -21,9 +21,16 @@ Indexes Doc: https://electrodb.dev/en/modeling/indexes/ */ const schema = new SchemaBuilder(FixEntitySuggestion, FixEntitySuggestionCollection) + // Use composite primary key: suggestionId (PK) + fixEntityId (SK) + .withPrimaryPartitionKeys(['suggestionId']) + .withPrimarySortKeys(['fixEntityId']) + // Manually add suggestionId attribute since we're not using belongs_to reference + .addAttribute('suggestionId', { + type: 'string', + required: true, + }) // Reference to FixEntity (many-to-one relationship from junction table) - .addReference('belongs_to', 'FixEntity') - // Reference to Suggestion (many-to-one relationship from junction table) - .addReference('belongs_to', 'Suggestion'); + // This creates GSI1 for querying by fixEntityId + .addReference('belongs_to', 'FixEntity'); 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 index 296d007a7..b923fe80a 100644 --- 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 @@ -10,13 +10,12 @@ * governing permissions and limitations under the License. */ -import type { BaseCollection, BaseModel, FixEntity, Suggestion } from '../index'; +import type { BaseCollection, BaseModel, FixEntity } from '../index'; export interface FixEntitySuggestion extends BaseModel { getFixEntity(): Promise; getFixEntityId(): string; setFixEntityId(value: string): this; - getSuggestion(): Promise; getSuggestionId(): string; setSuggestionId(value: string): this; } @@ -25,5 +24,6 @@ export interface FixEntitySuggestionCollection extends BaseCollection; allBySuggestionId(suggestionId: string): Promise; createRelationship(fixEntityId: string, suggestionId: string): Promise; - removeRelationship(fixEntityId: string, suggestionId: string): Promise; + findRelationship(suggestionId: string, fixEntityId: string): Promise; + removeRelationship(suggestionId: string, fixEntityId: string): Promise; } 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..e859aeec1 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.collection.test.js @@ -0,0 +1,244 @@ +/* + * 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 sinonChai from 'sinon-chai'; +import { stub } from 'sinon'; + +import FixEntitySuggestion from '../../../../src/models/fix-entity-suggestion/fix-entity-suggestion.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('FixEntitySuggestionCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + suggestionId: 'suggestion-123', + fixEntityId: 'fix-456', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(FixEntitySuggestion, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the FixEntitySuggestionCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); + + describe('allByFixEntityId', () => { + it('calls allByForeignKey with correct parameters', async () => { + const fixEntityId = 'fix-entity-123'; + + // Mock the inherited allByForeignKey method + const allByForeignKeyStub = stub().resolves([model]); + instance.allByForeignKey = allByForeignKeyStub; + + const result = await instance.allByFixEntityId(fixEntityId); + + expect(allByForeignKeyStub).to.have.been.calledOnceWith('fixEntityId', fixEntityId); + expect(result).to.deep.equal([model]); + }); + }); + + describe('allBySuggestionId', () => { + it('successfully queries by suggestionId using primary index', async () => { + const suggestionId = 'suggestion-123'; + const mockJunctionRecords = [ + { suggestionId, fixEntityId: 'fix-456' }, + { suggestionId, fixEntityId: 'fix-789' }, + ]; + + // Set up the entity query mock + const queryStub = stub().returns({ + go: stub().resolves({ data: mockJunctionRecords }), + }); + + mockElectroService.entities.fixEntitySuggestion.query.primary = queryStub; + + // Mock createInstance method + const createInstanceStub = stub() + .onFirstCall() + .returns({ id: 'instance-1' }) + .onSecondCall() + .returns({ id: 'instance-2' }); + instance.createInstance = createInstanceStub; + + const result = await instance.allBySuggestionId(suggestionId); + + expect(queryStub).to.have.been.calledWith({ suggestionId }); + expect(createInstanceStub).to.have.been.calledTwice; + expect(result).to.deep.equal([{ id: 'instance-1' }, { id: 'instance-2' }]); + }); + + it('logs and throws error when query fails', async () => { + const suggestionId = 'suggestion-123'; + const error = new Error('Database query failed'); + + const queryStub = stub().returns({ + go: stub().rejects(error), + }); + + mockElectroService.entities.fixEntitySuggestion.query.primary = queryStub; + + await expect(instance.allBySuggestionId(suggestionId)) + .to.be.rejectedWith('Database query failed'); + + expect(mockLogger.error).to.have.been.calledWith( + 'Failed to query FixEntitySuggestions by suggestionId: Database query failed', + ); + }); + }); + + describe('createRelationship', () => { + it('creates a relationship between fix entity and suggestion', async () => { + const fixEntityId = 'fix-456'; + const suggestionId = 'suggestion-123'; + const expectedData = { fixEntityId, suggestionId }; + + const createStub = stub().resolves(model); + instance.create = createStub; + + const result = await instance.createRelationship(fixEntityId, suggestionId); + + expect(createStub).to.have.been.calledOnceWith(expectedData); + expect(result).to.equal(model); + }); + }); + + describe('findRelationship', () => { + it('successfully finds an existing relationship', async () => { + const suggestionId = 'suggestion-123'; + const fixEntityId = 'fix-456'; + + const getStub = stub().returns({ + go: stub().resolves({ data: mockRecord }), + }); + + mockElectroService.entities.fixEntitySuggestion.get = getStub; + + const createInstanceStub = stub().returns(model); + instance.createInstance = createInstanceStub; + + const result = await instance.findRelationship(suggestionId, fixEntityId); + + expect(getStub).to.have.been.calledWith({ suggestionId, fixEntityId }); + expect(createInstanceStub).to.have.been.calledWith(mockRecord); + expect(result).to.equal(model); + }); + + it('returns null when relationship is not found', async () => { + const suggestionId = 'suggestion-123'; + const fixEntityId = 'fix-456'; + const notFoundError = new Error('Item not found'); + + const getStub = stub().returns({ + go: stub().rejects(notFoundError), + }); + + mockElectroService.entities.fixEntitySuggestion.get = getStub; + + const result = await instance.findRelationship(suggestionId, fixEntityId); + + expect(result).to.be.null; + }); + + it('throws error for other database errors', async () => { + const suggestionId = 'suggestion-123'; + const fixEntityId = 'fix-456'; + const error = new Error('Database connection failed'); + + const getStub = stub().returns({ + go: stub().rejects(error), + }); + + mockElectroService.entities.fixEntitySuggestion.get = getStub; + + await expect(instance.findRelationship(suggestionId, fixEntityId)) + .to.be.rejectedWith('Database connection failed'); + }); + }); + + describe('removeRelationship', () => { + it('successfully removes an existing relationship', async () => { + const suggestionId = 'suggestion-123'; + const fixEntityId = 'fix-456'; + + const deleteStub = stub().returns({ + go: stub().resolves({}), + }); + + mockElectroService.entities.fixEntitySuggestion.delete = deleteStub; + + await instance.removeRelationship(suggestionId, fixEntityId); + + expect(deleteStub).to.have.been.calledWith({ suggestionId, fixEntityId }); + }); + + it('ignores "not found" errors when removing non-existent relationship', async () => { + const suggestionId = 'suggestion-123'; + const fixEntityId = 'fix-456'; + const notFoundError = new Error('Item not found'); + + const deleteStub = stub().returns({ + go: stub().rejects(notFoundError), + }); + + mockElectroService.entities.fixEntitySuggestion.delete = deleteStub; + + // Should not throw an error + await expect(instance.removeRelationship(suggestionId, fixEntityId)) + .to.be.fulfilled; + }); + + it('throws error for other database errors during removal', async () => { + const suggestionId = 'suggestion-123'; + const fixEntityId = 'fix-456'; + const error = new Error('Database connection failed'); + + const deleteStub = stub().returns({ + go: stub().rejects(error), + }); + + mockElectroService.entities.fixEntitySuggestion.delete = deleteStub; + + await expect(instance.removeRelationship(suggestionId, fixEntityId)) + .to.be.rejectedWith('Database connection failed'); + }); + }); +}); 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..4dec784dc --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.model.test.js @@ -0,0 +1,46 @@ +/* + * 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 FixEntitySuggestion from '../../../../src/models/fix-entity-suggestion/fix-entity-suggestion.model.js'; +import BaseModel from '../../../../src/models/base/base.model.js'; + +describe('FixEntitySuggestion Model', () => { + describe('class definition', () => { + it('extends BaseModel', () => { + expect(FixEntitySuggestion.prototype).to.be.instanceOf(BaseModel); + }); + + it('has the correct DEFAULT_UPDATED_BY constant', () => { + expect(FixEntitySuggestion.DEFAULT_UPDATED_BY).to.equal('spacecat'); + }); + + it('can be instantiated', () => { + // Note: In a real scenario, FixEntitySuggestion would be instantiated through the collection + // This test just verifies the class structure + expect(FixEntitySuggestion).to.be.a('function'); + expect(FixEntitySuggestion.name).to.equal('FixEntitySuggestion'); + }); + }); + + describe('junction table functionality', () => { + it('represents a many-to-many relationship between FixEntity and Suggestion', () => { + // This is more of a documentation test - the junction table allows: + // - One FixEntity to be associated with multiple Suggestions + // - One Suggestion to be associated with multiple FixEntities + expect(FixEntitySuggestion.prototype).to.be.instanceOf(BaseModel); + }); + }); +}); diff --git a/packages/spacecat-shared-html-analyzer/xunit.xml b/packages/spacecat-shared-html-analyzer/xunit.xml new file mode 100644 index 000000000..387039395 --- /dev/null +++ b/packages/spacecat-shared-html-analyzer/xunit.xml @@ -0,0 +1,40 @@ + +Cheerio is required for Node.js environments. Please install it: npm install cheerio +Error: Cheerio is required for Node.js environments. Please install it: npm install cheerio + at filterHtmlNode (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/html-filter.js:243:11) + at async analyzeTextComparison (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/analyzer.js:35:20) + at async Context.<anonymous> (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/test/index.test.js:27:22) +Cheerio is required for Node.js environments. Please install it: npm install cheerio +Error: Cheerio is required for Node.js environments. Please install it: npm install cheerio + at filterHtmlNode (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/html-filter.js:243:11) + at async analyzeTextComparison (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/analyzer.js:35:20) + at async Context.<anonymous> (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/test/index.test.js:37:22) +Cheerio is required for Node.js environments. Please install it: npm install cheerio +Error: Cheerio is required for Node.js environments. Please install it: npm install cheerio + at filterHtmlNode (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/html-filter.js:243:11) + at async analyzeTextComparison (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/analyzer.js:36:19) + at async Context.<anonymous> (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/test/index.test.js:44:22) +Cheerio is required for Node.js environments. Please install it: npm install cheerio +Error: Cheerio is required for Node.js environments. Please install it: npm install cheerio + at filterHtmlNode (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/html-filter.js:243:11) + at async calculateStats (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/analyzer.js:71:24) + at async Context.<anonymous> (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/test/index.test.js:53:22) +Cheerio is required for Node.js environments. Please install it: npm install cheerio +Error: Cheerio is required for Node.js environments. Please install it: npm install cheerio + at filterHtmlNode (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/html-filter.js:243:11) + at async calculateStats (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/analyzer.js:71:24) + at async calculateBothScenarioStats (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/analyzer.js:106:24) + at async Context.<anonymous> (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/test/index.test.js:67:22) +Cheerio is required for Node.js environments. Please install it: npm install cheerio +Error: Cheerio is required for Node.js environments. Please install it: npm install cheerio + at filterHtmlNode (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/html-filter.js:243:11) + at async Context.<anonymous> (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/test/index.test.js:79:20) +Cheerio is required for Node.js environments. Please install it: npm install cheerio +Error: Cheerio is required for Node.js environments. Please install it: npm install cheerio + at filterHtmlNode (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/html-filter.js:243:11) + at async Context.<anonymous> (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/test/index.test.js:91:20) +Cheerio is required for Node.js environments. Please install it: npm install cheerio +Error: Cheerio is required for Node.js environments. Please install it: npm install cheerio + at filterHtmlNode (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/src/html-filter.js:243:11) + at async Context.<anonymous> (file:///Users/sandsinh/Desktop/aso/spacecat-shared/packages/spacecat-shared-html-analyzer/test/index.test.js:101:20) + From 0849a407faf6fb90144853c2c472ff11d02052f3 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Thu, 2 Oct 2025 08:26:45 +0530 Subject: [PATCH 03/12] fix: adds custom methods --- .../ELECTRODB-SCHEMAS.md | 52 - .../electrodb-schemas.json | 3101 ----------------- .../src/models/base/base.collection.js | 53 + .../src/models/base/index.d.ts | 1 + .../fix-entity-suggestion.collection.js | 70 - .../fix-entity-suggestion.model.js | 2 - .../fix-entity-suggestion.schema.js | 9 +- .../models/fix-entity-suggestion/index.d.ts | 6 +- .../fix-entity/fix-entity.collection.js | 146 +- .../models/fix-entity/fix-entity.schema.js | 4 +- .../src/models/fix-entity/index.d.ts | 5 +- .../src/models/suggestion/index.d.ts | 4 +- .../suggestion/suggestion.collection.js | 149 + .../models/suggestion/suggestion.schema.js | 2 +- .../unit/models/base/base.collection.test.js | 181 + .../test/unit/models/base/base.model.test.js | 13 +- .../fix-entity-suggestion.collection.test.js | 244 -- .../fix-entity-suggestion.model.test.js | 46 - .../fix-entity/fix-entity.collection.test.js | 258 +- .../suggestion/suggestion.collection.test.js | 251 ++ 20 files changed, 1033 insertions(+), 3564 deletions(-) delete mode 100644 packages/spacecat-shared-data-access/ELECTRODB-SCHEMAS.md delete mode 100644 packages/spacecat-shared-data-access/electrodb-schemas.json delete mode 100644 packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.collection.test.js delete mode 100644 packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.model.test.js diff --git a/packages/spacecat-shared-data-access/ELECTRODB-SCHEMAS.md b/packages/spacecat-shared-data-access/ELECTRODB-SCHEMAS.md deleted file mode 100644 index b5c91d50e..000000000 --- a/packages/spacecat-shared-data-access/ELECTRODB-SCHEMAS.md +++ /dev/null @@ -1,52 +0,0 @@ -# ElectroDB Entity Schemas - -Generated on: 2025-09-23T05:00:03.371Z -Total Entities: 27 - -## Many-to-Many Relationship Implementation - -This schema includes the implementation of a many-to-many relationship between `FixEntity` and `Suggestion` entities through the `FixEntitySuggestion` junction table. - -### Key Changes: -- **FixEntity**: Now connects to Suggestions via FixEntitySuggestion junction table -- **Suggestion**: Now connects to FixEntities via FixEntitySuggestion junction table -- **FixEntitySuggestion**: New junction entity enabling many-to-many relationships - -### Relationship Flow: -``` -FixEntity ←→ FixEntitySuggestion ←→ Suggestion -``` - -## Entity List - -- `apiKey` -- `asyncJob` -- `audit` -- `configuration` -- `entitlement` -- `experiment` -- `fixEntity` -- `fixEntitySuggestion` -- `importJob` -- `importUrl` -- `keyEvent` -- `latestAudit` -- `opportunity` -- `organization` -- `organizationIdentityProvider` -- `pageIntent` -- `report` -- `scrapeJob` -- `scrapeUrl` -- `site` -- `siteCandidate` -- `siteEnrollment` -- `siteTopForm` -- `siteTopPage` -- `suggestion` -- `trialUser` -- `trialUserActivity` - -## Schema Details - -See the complete schemas in `electrodb-schemas.json`. diff --git a/packages/spacecat-shared-data-access/electrodb-schemas.json b/packages/spacecat-shared-data-access/electrodb-schemas.json deleted file mode 100644 index b126d16e0..000000000 --- a/packages/spacecat-shared-data-access/electrodb-schemas.json +++ /dev/null @@ -1,3101 +0,0 @@ -{ - "metadata": { - "generatedAt": "2025-09-23T05:08:20.005Z", - "totalEntities": 27, - "description": "Optimized ElectroDB schemas with efficient index usage for FixEntitySuggestion junction table" - }, - "entities": { - "apiKey": { - "model": { - "entity": "ApiKey", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "apiKeyId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "hashedApiKey": { - "type": "string", - "required": true - }, - "imsUserId": { - "type": "string", - "default": "default" - }, - "imsOrgId": { - "type": "string", - "default": "default" - }, - "name": { - "type": "string", - "required": true - }, - "deletedAt": { - "type": "string" - }, - "expiresAt": { - "type": "string" - }, - "revokedAt": { - "type": "string" - }, - "scopes": { - "type": "list", - "required": true, - "items": { - "type": "map", - "properties": { - "actions": { - "type": "list", - "items": { - "type": "string" - } - }, - "domains": { - "type": "list", - "items": { - "type": "string" - } - }, - "name": { - "type": [ - "sites.read_all", - "sites.write_all", - "organizations.read_all", - "organizations.write_all", - "audits.read_all", - "audits.write_all", - "imports.read", - "imports.write", - "imports.delete", - "imports.read_all", - "imports.all_domains", - "imports.assistant" - ] - } - } - } - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "apiKeyId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "other", - "pk": { - "field": "gsi1pk", - "composite": [ - "hashedApiKey" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "other", - "pk": { - "field": "gsi2pk", - "composite": [ - "imsOrgId", - "imsUserId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "asyncJob": { - "model": { - "entity": "AsyncJob", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "asyncJobId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "recordExpiresAt": { - "type": "number", - "required": true, - "readOnly": true - }, - "status": { - "type": [ - "IN_PROGRESS", - "COMPLETED", - "FAILED", - "CANCELLED" - ], - "required": true - }, - "resultLocation": { - "type": "string" - }, - "resultType": { - "type": "string" - }, - "result": { - "type": "any" - }, - "error": { - "type": "map", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "any" - } - } - }, - "metadata": { - "type": "any" - }, - "startedAt": { - "type": "string", - "required": true, - "readOnly": true - }, - "endedAt": { - "type": "string" - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "asyncJobId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "other", - "pk": { - "field": "gsi1pk", - "composite": [ - "status" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "audit": { - "model": { - "entity": "Audit", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "auditId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "siteId": { - "type": "string", - "required": true - }, - "auditResult": { - "type": "any", - "required": true - }, - "auditType": { - "type": "string", - "required": true - }, - "fullAuditRef": { - "type": "string", - "required": true - }, - "isLive": { - "type": "boolean", - "required": true, - "default": false - }, - "isError": { - "type": "boolean", - "required": true, - "default": false - }, - "auditedAt": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "auditId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "auditType", - "auditedAt" - ] - } - } - } - }, - "configuration": { - "model": { - "entity": "Configuration", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "configurationId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "handlers": { - "type": "any" - }, - "jobs": { - "type": "list", - "items": { - "type": "map", - "properties": { - "group": { - "type": [ - "audits", - "imports", - "reports", - "scrapes" - ], - "required": true - }, - "type": { - "type": "string", - "required": true - }, - "interval": { - "type": [ - "never", - "every-hour", - "daily", - "weekly", - "every-saturday", - "every-sunday", - "fortnightly", - "fortnightly-saturday", - "fortnightly-sunday", - "monthly" - ], - "required": true - } - } - } - }, - "queues": { - "type": "any", - "required": true - }, - "slackRoles": { - "type": "any" - }, - "version": { - "type": "number", - "required": true, - "readOnly": true - }, - "versionString": { - "type": "string", - "required": true, - "readOnly": true, - "default": "0" - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "configurationId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "all", - "pk": { - "field": "gsi1pk", - "template": "ALL_CONFIGURATIONS" - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "versionString" - ] - } - } - } - }, - "entitlement": { - "model": { - "entity": "Entitlement", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "entitlementId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "organizationId": { - "type": "string", - "required": true - }, - "productCode": { - "type": [ - "LLMO", - "ASO" - ], - "required": true - }, - "tier": { - "type": [ - "FREE_TRIAL", - "PAID" - ], - "required": true - }, - "quotas": { - "type": "map", - "required": false, - "properties": { - "llmo_trial_prompts": { - "type": "number" - }, - "llmo_trial_prompts_consumed": { - "type": "number" - } - } - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "entitlementId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "organizationId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "other", - "pk": { - "field": "gsi2pk", - "composite": [ - "organizationId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "productCode" - ] - } - } - } - }, - "experiment": { - "model": { - "entity": "Experiment", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "experimentId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": true, - "default": "spacecat" - }, - "siteId": { - "type": "string", - "required": true - }, - "conversionEventName": { - "type": "string" - }, - "conversionEventValue": { - "type": "string" - }, - "endDate": { - "type": "string" - }, - "expId": { - "type": "string", - "required": true - }, - "name": { - "type": "string" - }, - "startDate": { - "type": "string" - }, - "status": { - "type": [ - "ACTIVE", - "INACTIVE" - ], - "required": true - }, - "type": { - "type": "string" - }, - "url": { - "type": "string", - "required": true - }, - "variants": { - "type": "list", - "items": { - "type": "any" - }, - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "experimentId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "expId", - "url", - "updatedAt" - ] - } - } - } - }, - "fixEntity": { - "model": { - "entity": "FixEntity", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "fixEntityId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "opportunityId": { - "type": "string", - "required": true - }, - "type": { - "type": [ - "CODE_CHANGE", - "CONTENT_UPDATE", - "REDIRECT_UPDATE", - "METADATA_UPDATE", - "AI_INSIGHTS" - ], - "required": true, - "readOnly": true - }, - "executedBy": { - "type": "string" - }, - "executedAt": { - "type": "string" - }, - "publishedAt": { - "type": "string" - }, - "changeDetails": { - "type": "any", - "required": true - }, - "status": { - "type": [ - "PENDING", - "DEPLOYED", - "PUBLISHED", - "FAILED", - "ROLLED_BACK" - ], - "required": true, - "default": "PENDING" - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "fixEntityId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "opportunityId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "status" - ] - } - } - } - }, - "fixEntitySuggestion": { - "model": { - "entity": "FixEntitySuggestion", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "fixEntitySuggestionId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "suggestionId": { - "type": "string", - "required": true - }, - "fixEntityId": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "suggestionId" - ] - }, - "sk": { - "field": "sk", - "composite": [ - "fixEntityId" - ] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "fixEntityId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "importJob": { - "model": { - "entity": "ImportJob", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "importJobId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "baseURL": { - "type": "string", - "required": true - }, - "duration": { - "type": "number", - "default": 0 - }, - "endedAt": { - "type": "string" - }, - "failedCount": { - "type": "number", - "default": 0 - }, - "hasCustomHeaders": { - "type": "boolean", - "default": false - }, - "hasCustomImportJs": { - "type": "boolean", - "default": false - }, - "hashedApiKey": { - "type": "string", - "required": true - }, - "importQueueId": { - "type": "string" - }, - "initiatedBy": { - "type": "map", - "properties": { - "apiKeyName": { - "type": "string" - }, - "imsOrgId": { - "type": "string" - }, - "imsUserId": { - "type": "string" - }, - "userAgent": { - "type": "string" - } - } - }, - "options": { - "type": "any" - }, - "redirectCount": { - "type": "number", - "default": 0 - }, - "status": { - "type": [ - "RUNNING", - "COMPLETE", - "FAILED", - "STOPPED" - ], - "required": true - }, - "startedAt": { - "type": "string", - "required": true, - "readOnly": true - }, - "successCount": { - "type": "number", - "default": 0 - }, - "urlCount": { - "type": "number", - "default": 0 - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "importJobId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "all", - "pk": { - "field": "gsi1pk", - "template": "ALL_IMPORTJOBS" - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "startedAt" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "other", - "pk": { - "field": "gsi2pk", - "composite": [ - "status" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "importUrl": { - "model": { - "entity": "ImportUrl", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "importUrlId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "recordExpiresAt": { - "type": "number", - "required": true, - "readOnly": true - }, - "importJobId": { - "type": "string", - "required": true - }, - "file": { - "type": "string" - }, - "path": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "status": { - "type": [ - "PENDING", - "REDIRECT", - "RUNNING", - "COMPLETE", - "FAILED", - "STOPPED" - ], - "required": true - }, - "url": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "importUrlId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "importJobId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "status" - ] - } - } - } - }, - "keyEvent": { - "model": { - "entity": "KeyEvent", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "keyEventId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "siteId": { - "type": "string", - "required": true - }, - "name": { - "type": "string", - "required": true - }, - "type": { - "type": [ - "PERFORMANCE", - "SEO", - "CONTENT", - "CODE", - "THIRD PARTY", - "EXPERIMENTATION", - "NETWORK", - "STATUS CHANGE" - ], - "required": true - }, - "time": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "keyEventId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "time" - ] - } - } - } - }, - "latestAudit": { - "model": { - "entity": "LatestAudit", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "latestAuditId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "siteId": { - "type": "string", - "required": true - }, - "auditId": { - "type": "string", - "required": true - }, - "auditResult": { - "type": "any", - "required": true - }, - "auditType": { - "type": "string", - "required": true - }, - "fullAuditRef": { - "type": "string", - "required": true - }, - "isLive": { - "type": "boolean", - "required": true, - "default": false - }, - "isError": { - "type": "boolean", - "required": true, - "default": false - }, - "auditedAt": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "sk", - "composite": [ - "auditType" - ] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "all", - "pk": { - "field": "gsi1pk", - "template": "ALL_LATESTAUDITS" - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "auditType" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi2pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "auditType" - ] - } - }, - "spacecat-data-gsi3pk-gsi3sk": { - "index": "spacecat-data-gsi3pk-gsi3sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi3pk", - "composite": [ - "auditId" - ] - }, - "sk": { - "field": "gsi3sk", - "composite": [ - "auditType" - ] - } - } - } - }, - "opportunity": { - "model": { - "entity": "Opportunity", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "opportunityId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "siteId": { - "type": "string", - "required": true - }, - "auditId": { - "type": "string", - "required": false - }, - "latestAuditId": { - "type": "string", - "required": false - }, - "runbook": { - "type": "string" - }, - "type": { - "type": "string", - "readOnly": true, - "required": true - }, - "data": { - "type": "any" - }, - "origin": { - "type": [ - "ESS_OPS", - "AI", - "AUTOMATION" - ], - "required": true - }, - "title": { - "type": "string", - "required": true - }, - "description": { - "type": "string" - }, - "status": { - "type": [ - "NEW", - "IN_PROGRESS", - "IGNORED", - "RESOLVED" - ], - "required": true, - "default": "NEW" - }, - "guidance": { - "type": "any" - }, - "tags": { - "type": "set", - "items": "string" - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "opportunityId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "status", - "updatedAt" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi2pk", - "composite": [ - "auditId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi3pk-gsi3sk": { - "index": "spacecat-data-gsi3pk-gsi3sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi3pk", - "composite": [ - "latestAuditId" - ] - }, - "sk": { - "field": "gsi3sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "organization": { - "model": { - "entity": "Organization", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "organizationId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "config": { - "type": "any", - "required": true, - "default": { - "slack": {}, - "handlers": {} - } - }, - "name": { - "type": "string", - "required": true - }, - "imsOrgId": { - "type": "string" - }, - "fulfillableItems": { - "type": "any" - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "organizationId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "all", - "pk": { - "field": "gsi1pk", - "template": "ALL_ORGANIZATIONS" - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "imsOrgId" - ] - } - } - } - }, - "organizationIdentityProvider": { - "model": { - "entity": "OrganizationIdentityProvider", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "organizationIdentityProviderId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "organizationId": { - "type": "string", - "required": true - }, - "metadata": { - "type": "any", - "required": false - }, - "provider": { - "type": [ - "IMS", - "MICROSOFT", - "GOOGLE" - ], - "required": true - }, - "externalId": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "organizationIdentityProviderId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "organizationId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "other", - "pk": { - "field": "gsi2pk", - "composite": [ - "provider" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "externalId" - ] - } - } - } - }, - "pageIntent": { - "model": { - "entity": "PageIntent", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "pageIntentId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "default": "spacecat" - }, - "siteId": { - "type": "string", - "required": true - }, - "url": { - "type": "string", - "required": true - }, - "pageIntent": { - "type": [ - "INFORMATIONAL", - "NAVIGATIONAL", - "TRANSACTIONAL", - "COMMERCIAL" - ], - "required": true - }, - "topic": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "pageIntentId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "other", - "pk": { - "field": "gsi2pk", - "composite": [ - "url" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "report": { - "model": { - "entity": "Report", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "reportId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "siteId": { - "type": "string", - "required": true - }, - "reportType": { - "type": "string", - "required": true - }, - "reportPeriod": { - "type": "any", - "required": true - }, - "comparisonPeriod": { - "type": "any", - "required": true - }, - "storagePath": { - "type": "string", - "required": false - }, - "status": { - "type": [ - "processing", - "success", - "failed" - ], - "required": true, - "default": "processing" - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "reportId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "all", - "pk": { - "field": "gsi1pk", - "template": "ALL_REPORTS" - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "reportType" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi2pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "scrapeJob": { - "model": { - "entity": "ScrapeJob", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "scrapeJobId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "recordExpiresAt": { - "type": "number", - "required": true, - "readOnly": true - }, - "baseURL": { - "type": "string", - "required": true - }, - "processingType": { - "type": "string", - "required": true - }, - "duration": { - "type": "number", - "default": 0 - }, - "endedAt": { - "type": "string" - }, - "failedCount": { - "type": "number", - "default": 0 - }, - "scrapeQueueId": { - "type": "string" - }, - "options": { - "type": "any" - }, - "customHeaders": { - "type": "any" - }, - "redirectCount": { - "type": "number", - "default": 0 - }, - "status": { - "type": [ - "RUNNING", - "COMPLETE", - "FAILED", - "STOPPED" - ], - "required": true - }, - "startedAt": { - "type": "string", - "required": true, - "readOnly": true - }, - "successCount": { - "type": "number", - "default": 0 - }, - "urlCount": { - "type": "number", - "default": 0 - }, - "results": { - "type": "any" - }, - "optEnableJavascript": { - "type": "string", - "hidden": true, - "readOnly": true, - "watch": [ - "options" - ] - }, - "optHideConsentBanner": { - "type": "string", - "hidden": true, - "readOnly": true, - "watch": [ - "options" - ] - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "scrapeJobId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "all", - "pk": { - "field": "gsi1pk", - "template": "ALL_SCRAPEJOBS" - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "startedAt" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "other", - "pk": { - "field": "gsi2pk", - "composite": [ - "baseURL" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "processingType", - "startedAt" - ] - } - }, - "spacecat-data-gsi3pk-gsi3sk": { - "index": "spacecat-data-gsi3pk-gsi3sk", - "indexType": "other", - "pk": { - "field": "gsi3pk", - "composite": [ - "baseURL", - "processingType" - ] - }, - "sk": { - "field": "gsi3sk", - "composite": [ - "optEnableJavascript", - "optHideConsentBanner", - "startedAt" - ] - } - }, - "spacecat-data-gsi4pk-gsi4sk": { - "index": "spacecat-data-gsi4pk-gsi4sk", - "indexType": "other", - "pk": { - "field": "gsi4pk", - "composite": [ - "status" - ] - }, - "sk": { - "field": "gsi4sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "scrapeUrl": { - "model": { - "entity": "ScrapeUrl", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "scrapeUrlId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "recordExpiresAt": { - "type": "number", - "required": true, - "readOnly": true - }, - "scrapeJobId": { - "type": "string", - "required": true - }, - "file": { - "type": "string" - }, - "path": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "status": { - "type": [ - "PENDING", - "REDIRECT", - "RUNNING", - "COMPLETE", - "FAILED", - "STOPPED" - ], - "required": true - }, - "url": { - "type": "string", - "required": true - }, - "isOriginal": { - "type": "boolean", - "default": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "scrapeUrlId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "scrapeJobId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "status" - ] - } - } - } - }, - "site": { - "model": { - "entity": "Site", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "siteId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "organizationId": { - "type": "string", - "required": true - }, - "baseURL": { - "type": "string", - "required": true - }, - "name": { - "type": "string" - }, - "config": { - "type": "any", - "required": true, - "default": { - "slack": {}, - "handlers": {} - } - }, - "deliveryType": { - "type": [ - "aem_cs", - "aem_edge", - "aem_ams", - "aem_headless", - "other" - ], - "default": "aem_edge", - "required": true - }, - "authoringType": { - "type": [ - "cs/crosswalk", - "cs", - "sharepoint", - "googledocs", - "documentauthoring" - ], - "required": false - }, - "gitHubURL": { - "type": "string" - }, - "deliveryConfig": { - "type": "any", - "default": {}, - "properties": { - "programId": { - "type": "string" - }, - "environmentId": { - "type": "string" - }, - "authorURL": { - "type": "string" - }, - "siteId": { - "type": "string" - } - } - }, - "hlxConfig": { - "type": "any", - "default": {} - }, - "isSandbox": { - "type": "boolean", - "default": false - }, - "isLive": { - "type": "boolean", - "required": true, - "default": false - }, - "isLiveToggledAt": { - "type": "string", - "watch": [ - "isLive" - ] - }, - "externalOwnerId": { - "type": "string", - "hidden": true, - "readOnly": true, - "watch": [ - "authoringType", - "hlxConfig", - "deliveryConfig" - ] - }, - "externalSiteId": { - "type": "string", - "hidden": true, - "readOnly": true, - "watch": [ - "authoringType", - "hlxConfig", - "deliveryConfig" - ] - }, - "pageTypes": { - "type": "list", - "required": false, - "items": { - "type": "map", - "required": true, - "properties": { - "name": { - "type": "string", - "required": true - }, - "pattern": { - "type": "string", - "required": true - } - } - } - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "all", - "pk": { - "field": "gsi1pk", - "template": "ALL_SITES" - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "baseURL" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi2pk", - "composite": [ - "organizationId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi3pk-gsi3sk": { - "index": "spacecat-data-gsi3pk-gsi3sk", - "indexType": "other", - "pk": { - "field": "gsi3pk", - "composite": [ - "deliveryType" - ] - }, - "sk": { - "field": "gsi3sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi4pk-gsi4sk": { - "index": "spacecat-data-gsi4pk-gsi4sk", - "indexType": "other", - "pk": { - "field": "gsi4pk", - "composite": [ - "externalOwnerId" - ] - }, - "sk": { - "field": "gsi4sk", - "composite": [ - "externalSiteId" - ] - } - } - } - }, - "siteCandidate": { - "model": { - "entity": "SiteCandidate", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "siteCandidateId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string" - }, - "siteId": { - "type": "string" - }, - "baseURL": { - "type": "string", - "required": true - }, - "hlxConfig": { - "type": "any", - "required": true, - "default": {} - }, - "source": { - "type": [ - "SPACECAT_SLACK_BOT", - "RUM", - "CDN" - ], - "required": true - }, - "status": { - "type": [ - "PENDING", - "IGNORED", - "APPROVED", - "ERROR" - ], - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "siteCandidateId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "all", - "pk": { - "field": "gsi1pk", - "template": "ALL_SITECANDIDATES" - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "baseURL" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi2pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "siteEnrollment": { - "model": { - "entity": "SiteEnrollment", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "siteEnrollmentId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "siteId": { - "type": "string", - "required": true - }, - "entitlementId": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "siteEnrollmentId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi2pk", - "composite": [ - "entitlementId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - } - } - }, - "siteTopForm": { - "model": { - "entity": "SiteTopForm", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "siteTopFormId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "siteId": { - "type": "string", - "required": true - }, - "url": { - "type": "string", - "required": true - }, - "formSource": { - "type": "string", - "required": false, - "default": "" - }, - "traffic": { - "type": "number", - "required": false, - "default": 0 - }, - "source": { - "type": "string", - "required": true - }, - "importedAt": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "siteTopFormId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "source", - "traffic" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "other", - "pk": { - "field": "gsi2pk", - "composite": [ - "url", - "formSource" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "traffic" - ] - } - } - } - }, - "siteTopPage": { - "model": { - "entity": "SiteTopPage", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "siteTopPageId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "siteId": { - "type": "string", - "required": true - }, - "url": { - "type": "string", - "required": true - }, - "traffic": { - "type": "number", - "required": true - }, - "source": { - "type": "string", - "required": true - }, - "topKeyword": { - "type": "string" - }, - "geo": { - "type": "string", - "required": false, - "default": "global" - }, - "importedAt": { - "type": "string", - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "siteTopPageId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "source", - "geo", - "traffic" - ] - } - } - } - }, - "suggestion": { - "model": { - "entity": "Suggestion", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "suggestionId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "opportunityId": { - "type": "string", - "required": true - }, - "type": { - "type": [ - "CODE_CHANGE", - "CONTENT_UPDATE", - "REDIRECT_UPDATE", - "METADATA_UPDATE", - "AI_INSIGHTS" - ], - "required": true, - "readOnly": true - }, - "rank": { - "type": "number", - "required": true - }, - "data": { - "type": "any", - "required": true - }, - "kpiDeltas": { - "type": "any" - }, - "status": { - "type": [ - "NEW", - "APPROVED", - "IN_PROGRESS", - "SKIPPED", - "FIXED", - "ERROR", - "OUTDATED" - ], - "required": true, - "default": "NEW" - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "suggestionId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "opportunityId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "status", - "rank" - ] - } - } - } - }, - "trialUser": { - "model": { - "entity": "TrialUser", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "trialUserId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "organizationId": { - "type": "string", - "required": true - }, - "externalUserId": { - "type": "string", - "required": false - }, - "status": { - "type": [ - "INVITED", - "REGISTERED", - "BLOCKED", - "DELETED" - ], - "required": true - }, - "provider": { - "type": [ - "IMS", - "MICROSOFT", - "GOOGLE" - ], - "required": false - }, - "lastSeenAt": { - "type": "string" - }, - "emailId": { - "type": "string", - "required": true - }, - "firstName": { - "type": "string", - "required": false - }, - "lastName": { - "type": "string", - "required": false - }, - "metadata": { - "type": "any" - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "trialUserId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "all", - "pk": { - "field": "gsi1pk", - "template": "ALL_TRIALUSERS" - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "emailId" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi2pk", - "composite": [ - "organizationId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi3pk-gsi3sk": { - "index": "spacecat-data-gsi3pk-gsi3sk", - "indexType": "other", - "pk": { - "field": "gsi3pk", - "composite": [ - "provider" - ] - }, - "sk": { - "field": "gsi3sk", - "composite": [ - "externalUserId" - ] - } - } - } - }, - "trialUserActivity": { - "model": { - "entity": "TrialUserActivity", - "version": "1", - "service": "SpaceCat" - }, - "attributes": { - "trialUserActivityId": { - "type": "string", - "required": true, - "readOnly": true - }, - "createdAt": { - "type": "string", - "readOnly": true, - "required": true - }, - "updatedAt": { - "type": "string", - "required": true, - "readOnly": true, - "watch": "*" - }, - "updatedBy": { - "type": "string", - "required": false, - "readOnly": false, - "watch": "*" - }, - "trialUserId": { - "type": "string", - "required": true - }, - "entitlementId": { - "type": "string", - "required": true - }, - "siteId": { - "type": "string", - "required": true - }, - "type": { - "type": [ - "SIGN_UP", - "SIGN_IN", - "CREATE_SITE", - "RUN_AUDIT", - "PROMPT_RUN", - "DOWNLOAD" - ], - "required": true - }, - "details": { - "type": "any" - }, - "productCode": { - "type": [ - "LLMO", - "ASO" - ], - "required": true - } - }, - "indexes": { - "primary": { - "pk": { - "field": "pk", - "composite": [ - "trialUserActivityId" - ] - }, - "sk": { - "field": "sk", - "composite": [] - } - }, - "spacecat-data-gsi1pk-gsi1sk": { - "index": "spacecat-data-gsi1pk-gsi1sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi1pk", - "composite": [ - "trialUserId" - ] - }, - "sk": { - "field": "gsi1sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi2pk-gsi2sk": { - "index": "spacecat-data-gsi2pk-gsi2sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi2pk", - "composite": [ - "entitlementId" - ] - }, - "sk": { - "field": "gsi2sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi3pk-gsi3sk": { - "index": "spacecat-data-gsi3pk-gsi3sk", - "indexType": "belongs_to", - "pk": { - "field": "gsi3pk", - "composite": [ - "siteId" - ] - }, - "sk": { - "field": "gsi3sk", - "composite": [ - "updatedAt" - ] - } - }, - "spacecat-data-gsi4pk-gsi4sk": { - "index": "spacecat-data-gsi4pk-gsi4sk", - "indexType": "other", - "pk": { - "field": "gsi4pk", - "composite": [ - "productCode" - ] - }, - "sk": { - "field": "gsi4sk", - "composite": [ - "createdAt" - ] - } - } - } - } - } -} \ No newline at end of file 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 7eea1ea54..3c0b61f22 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 @@ -375,6 +375,59 @@ 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. + * @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 batchGetByIds(ids) { + if (!isNonEmptyArray(ids)) { + const message = `Failed to batch get [${this.entityName}]: ids must be a non-empty array`; + this.log.error(message); + throw new DataAccessError(message); + } + + // Validate all IDs + ids.forEach((id, index) => { + try { + guardId(this.idName, id, this.entityName); + } catch (error) { + throw new DataAccessError(`Invalid ID at index ${index}: ${error.message}`, this, error); + } + }); + + try { + // Use ElectroDB's batch get + const result = await this.entity.get( + ids.map((id) => ({ [this.idName]: id })), + ).go(); + + // Process found entities + const data = result.data + .map((record) => this.#createInstance(record)) + .filter((entity) => entity !== null); + + // Extract unprocessed IDs + const unprocessed = result.unprocessed + ? result.unprocessed.map((item) => item[this.idName]) + : []; + + return { data, unprocessed }; + } catch (error) { + this.log.error(`Failed to batch get [${this.entityName}]`, error); + throw new DataAccessError('Failed to batch get entities', this, error); + } + } + /** * Finds a single entity by index keys. * @param {Object} keys - The index keys to use for the query. 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..e881a877f 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 @@ -49,6 +49,7 @@ export interface BaseCollection { _saveMany(items: T[]): Promise; all(sortKeys?: object, options?: QueryOptions): Promise; allByIndexKeys(keys: object, options?: QueryOptions): Promise; + batchGetByIds(ids: string[]): Promise<{ data: T[]; unprocessed: string[] }>; create(item: object): Promise; createMany(items: object[], parent?: T): Promise>; existsById(id: string): Promise; 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 index 1f5240913..2b874ac2d 100644 --- 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 @@ -21,77 +21,7 @@ import BaseCollection from '../base/base.collection.js'; * @extends BaseCollection */ class FixEntitySuggestionCollection extends BaseCollection { - /** - * Find all suggestions for a given fix entity - * @param {string} fixEntityId - The ID of the fix entity - * @returns {Promise} Array of junction records - */ - async allByFixEntityId(fixEntityId) { - return this.allByForeignKey('fixEntityId', fixEntityId); - } - /** - * Find all fix entities for a given suggestion using primary index - * @param {string} suggestionId - The ID of the suggestion - * @returns {Promise} Array of junction records - */ - async allBySuggestionId(suggestionId) { - try { - const result = await this.entity.query.primary({ suggestionId }).go(); - return result.data.map((item) => this.createInstance(item)); - } catch (error) { - this.log.error(`Failed to query FixEntitySuggestions by suggestionId: ${error.message}`); - throw error; - } - } - - /** - * Create a relationship between a fix entity and suggestion - * @param {string} fixEntityId - The ID of the fix entity - * @param {string} suggestionId - The ID of the suggestion - * @returns {Promise} The created junction record - */ - async createRelationship(fixEntityId, suggestionId) { - return this.create({ - fixEntityId, - suggestionId, - }); - } - - /** - * Find a specific relationship between a fix entity and suggestion - * @param {string} suggestionId - The ID of the suggestion (PK) - * @param {string} fixEntityId - The ID of the fix entity (SK) - * @returns {Promise} The junction record or null - */ - async findRelationship(suggestionId, fixEntityId) { - try { - const result = await this.entity.get({ suggestionId, fixEntityId }).go(); - return this.createInstance(result.data); - } catch (error) { - if (error.message?.includes('not found')) { - return null; - } - throw error; - } - } - - /** - * Remove a relationship between a fix entity and suggestion - * @param {string} suggestionId - The ID of the suggestion (PK) - * @param {string} fixEntityId - The ID of the fix entity (SK) - * @returns {Promise} - */ - async removeRelationship(suggestionId, fixEntityId) { - try { - await this.entity.delete({ suggestionId, fixEntityId }).go(); - } catch (error) { - // Ignore "not found" errors since the goal is to ensure the relationship doesn't exist - if (!error.message?.includes('not found')) { - throw error; - } - } - } } 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 index 579f5788e..e880052a0 100644 --- 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 @@ -22,8 +22,6 @@ import BaseModel from '../base/base.model.js'; */ class FixEntitySuggestion extends BaseModel { static DEFAULT_UPDATED_BY = 'spacecat'; - - // Add custom methods here for junction-specific functionality } 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 index adc5c5705..608994a65 100644 --- 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 @@ -21,16 +21,9 @@ Indexes Doc: https://electrodb.dev/en/modeling/indexes/ */ const schema = new SchemaBuilder(FixEntitySuggestion, FixEntitySuggestionCollection) - // Use composite primary key: suggestionId (PK) + fixEntityId (SK) .withPrimaryPartitionKeys(['suggestionId']) .withPrimarySortKeys(['fixEntityId']) - // Manually add suggestionId attribute since we're not using belongs_to reference - .addAttribute('suggestionId', { - type: 'string', - required: true, - }) - // Reference to FixEntity (many-to-one relationship from junction table) - // This creates GSI1 for querying by fixEntityId + .addReference('belongs_to', 'Suggestion') .addReference('belongs_to', 'FixEntity'); 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 index b923fe80a..58e152e63 100644 --- 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 @@ -10,10 +10,11 @@ * governing permissions and limitations under the License. */ -import type { BaseCollection, BaseModel, FixEntity } from '../index'; +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; @@ -23,7 +24,4 @@ export interface FixEntitySuggestion extends BaseModel { export interface FixEntitySuggestionCollection extends BaseCollection { allByFixEntityId(fixEntityId: string): Promise; allBySuggestionId(suggestionId: string): Promise; - createRelationship(fixEntityId: string, suggestionId: string): Promise; - findRelationship(suggestionId: string, fixEntityId: string): Promise; - removeRelationship(suggestionId: string, fixEntityId: string): Promise; } 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..f5b9eaa5c 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,9 +10,10 @@ * governing permissions and limitations under the License. */ import BaseCollection from '../base/base.collection.js'; +import DataAccessError from '../../errors/data-access.error.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. * @@ -20,7 +21,148 @@ import BaseCollection from '../base/base.collection.js'; * @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<{data: Array, unprocessed: Array}>} - A promise that resolves to an + * object containing: + * - data: Array of found Suggestion models + * - unprocessed: Array of suggestion IDs that couldn't be processed + * @throws {DataAccessError} - Throws an error if the fixEntityId is not provided or if the + * query fails. + */ + async getSuggestionsByFixEntityId(fixEntityId) { + if (!fixEntityId) { + const message = 'Failed to get suggestions: fixEntityId is required'; + this.log.error(message); + throw new DataAccessError(message); + } + + try { + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestion'); + + const fixEntitySuggestions = await fixEntitySuggestionCollection + .allByFixEntityId(fixEntityId); + + const suggestionIds = fixEntitySuggestions.map((record) => record.getSuggestionId()); + + if (suggestionIds.length === 0) { + return { data: [], unprocessed: [] }; + } + + const suggestionCollection = this.entityRegistry.getCollection('Suggestion'); + + return await suggestionCollection.batchGetByIds(suggestionIds).then((result) => ({ + data: result.data, + unprocessed: result.unprocessed, + })); + } 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} fixEntityId - The ID of the FixEntity. + * @param {Array} suggestions - An array of suggestion IDs (strings) or suggestion + * model instances. + * @returns {Promise<{createdItems: Array, errorItems: Array, removedCount: number}>} - A promise + * that resolves to an object containing: + * - createdItems: Array of created FixEntitySuggestion junction records + * - errorItems: Array of items that failed validation + * - removedCount: Number of existing relationships that were removed + * @throws {DataAccessError} - Throws an error if the fixEntityId is not provided or if the + * operation fails. + */ + async setSuggestionsByFixEntityId(fixEntityId, suggestions) { + if (!fixEntityId) { + const message = 'Failed to set suggestions: fixEntityId is required'; + this.log.error(message); + throw new DataAccessError(message); + } + + if (!Array.isArray(suggestions)) { + const message = 'Suggestions must be an array'; + this.log.error(message); + throw new DataAccessError(message); + } + + try { + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestion'); + + // Get current suggestion IDs + const currentSuggestionIds = new Set(); + const existingRelationships = await fixEntitySuggestionCollection + .allByFixEntityId(fixEntityId); + existingRelationships.forEach((rel) => currentSuggestionIds.add(rel.getSuggestionId())); + + // Get new suggestion IDs + const newSuggestionIds = new Set(); + suggestions.forEach((suggestion) => { + const suggestionId = typeof suggestion === 'string' + ? suggestion + : suggestion.getId(); + newSuggestionIds.add(suggestionId); + }); + + // Find what to remove (existing but not in new) + const toRemove = existingRelationships.filter( + (rel) => !newSuggestionIds.has(rel.getSuggestionId()), + ); + + // Find what to add (new but not existing) + const toAdd = suggestions.filter((suggestion) => { + const suggestionId = typeof suggestion === 'string' + ? suggestion + : suggestion.getId(); + return !currentSuggestionIds.has(suggestionId); + }); + + let removedCount = 0; + let createdItems = []; + let errorItems = []; + + // Remove relationships that are no longer needed + if (toRemove.length > 0) { + const removeIds = toRemove.map((rel) => rel.getId()); + await fixEntitySuggestionCollection.removeByIds(removeIds); + removedCount = removeIds.length; + } + + // Add new relationships + if (toAdd.length > 0) { + const junctionRecords = toAdd.map((suggestion) => { + const suggestionId = typeof suggestion === 'string' + ? suggestion + : suggestion.getId(); + + return { + fixEntityId, + suggestionId, + }; + }); + + const addResult = await fixEntitySuggestionCollection.createMany(junctionRecords); + createdItems = addResult.createdItems; + errorItems = addResult.errorItems; + } + + 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); + } + } } export default FixEntityCollection; 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 77b1a1e36..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', 'FixEntitySuggestion') + .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 64cd893fd..09d3f39dd 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, FixEntitySuggestion, + BaseCollection, BaseModel, Opportunity, Suggestion, FixEntitySuggestion, } from '../index'; export interface FixEntity extends BaseModel { @@ -28,7 +28,6 @@ export interface FixEntity extends BaseModel { setPublishedAt(value: string): this; getStatus(): string; setStatus(value: string): this; - getFixEntitySuggestions(): Promise; getType(): string; } @@ -37,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}>; + setSuggestionsByFixEntityId(fixEntityId: string, suggestions: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; } 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 c4c17e9a4..681a197d7 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,7 +10,7 @@ * governing permissions and limitations under the License. */ -import type { BaseCollection, BaseModel, Opportunity, FixEntitySuggestion } from '../index'; +import type { BaseCollection, BaseModel, Opportunity, FixEntitySuggestion, FixEntity } from '../index'; export interface Suggestion extends BaseModel { getData(): object; @@ -35,4 +35,6 @@ export interface SuggestionCollection extends BaseCollection { bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise; findByOpportunityId(opportunityId: string): Promise; findByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; + getFixEntitiesBySuggestionId(suggestionId: string): Promise<{data: Array, unprocessed: Array}>; + setFixEntitiesBySuggestionId(suggestionId: string, fixEntities: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; } 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 9d7ee4788..1f2a69ed0 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,6 +11,7 @@ */ import BaseCollection from '../base/base.collection.js'; +import DataAccessError from '../../errors/data-access.error.js'; import Suggestion from './suggestion.model.js'; /** @@ -52,6 +53,154 @@ 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<{data: Array, unprocessed: Array}>} - A promise that resolves to an + * object containing: + * - data: Array of found FixEntity models + * - unprocessed: Array of fix entity IDs that couldn't be processed + * @throws {DataAccessError} - Throws an error if the suggestionId is not provided or if the + * query fails. + */ + async getFixEntitiesBySuggestionId(suggestionId) { + if (!suggestionId) { + const message = 'Failed to get fix entities: suggestionId is required'; + this.log.error(message); + throw new DataAccessError(message); + } + + try { + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestion'); + + // Get all junction records for this suggestion + const fixEntitySuggestions = await fixEntitySuggestionCollection + .allBySuggestionId(suggestionId); + + // Extract fix entity IDs from junction records + const fixEntityIds = fixEntitySuggestions.map((record) => record.getFixEntityId()); + + if (fixEntityIds.length === 0) { + return { data: [], unprocessed: [] }; + } + + // Get the FixEntity collection from the entity registry + const fixEntityCollection = this.entityRegistry.getCollection('FixEntity'); + + // Get all fix entities by their IDs using batch get + return await fixEntityCollection.batchGetByIds(fixEntityIds).then((result) => ({ + data: result.data, + unprocessed: result.unprocessed, + })); + } 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); + } + } + + /** + * Sets FixEntities for a specific Suggestion by replacing all existing fix entities with new + * ones. + * This method efficiently only removes relationships that are no longer needed and only adds + * new ones. + * + * @async + * @param {string} suggestionId - The ID of the Suggestion. + * @param {Array} fixEntities - An array of fix entity IDs (strings) or fix entity + * model instances. + * @returns {Promise<{createdItems: Array, errorItems: Array, removedCount: number}>} - A promise + * that resolves to an object containing: + * - createdItems: Array of created FixEntitySuggestion junction records + * - errorItems: Array of items that failed validation + * - removedCount: Number of existing relationships that were removed + * @throws {DataAccessError} - Throws an error if the suggestionId is not provided or if the + * operation fails. + */ + async setFixEntitiesBySuggestionId(suggestionId, fixEntities) { + if (!suggestionId) { + const message = 'Failed to set fix entities: suggestionId is required'; + this.log.error(message); + throw new DataAccessError(message); + } + + if (!Array.isArray(fixEntities)) { + const message = 'Fix entities must be an array'; + this.log.error(message); + throw new DataAccessError(message); + } + + try { + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestion'); + + // Get current fix entity IDs + const currentFixEntityIds = new Set(); + const fixEntitySuggestions = await fixEntitySuggestionCollection + .allBySuggestionId(suggestionId); + fixEntitySuggestions.forEach((rel) => currentFixEntityIds.add(rel.getFixEntityId())); + + // Get new fix entity IDs + const newFixEntityIds = new Set(); + fixEntities.forEach((fixEntity) => { + const fixEntityId = typeof fixEntity === 'string' + ? fixEntity + : fixEntity.getId(); + newFixEntityIds.add(fixEntityId); + }); + + // Find what to remove (existing but not in new) + const toRemove = fixEntitySuggestions.filter( + (rel) => !newFixEntityIds.has(rel.getFixEntityId()), + ); + + // Find what to add (new but not existing) + const toAdd = fixEntities.filter((fixEntity) => { + const fixEntityId = typeof fixEntity === 'string' + ? fixEntity + : fixEntity.getId(); + return !currentFixEntityIds.has(fixEntityId); + }); + + let removedCount = 0; + let createdItems = []; + let errorItems = []; + + // Remove relationships that are no longer needed + if (toRemove.length > 0) { + const removeIds = toRemove.map((rel) => rel.getId()); + await fixEntitySuggestionCollection.removeByIds(removeIds); + removedCount = removeIds.length; + } + + // Add new relationships + if (toAdd.length > 0) { + const junctionRecords = toAdd.map((fixEntity) => { + const fixEntityId = typeof fixEntity === 'string' + ? fixEntity + : fixEntity.getId(); + + return { + suggestionId, + fixEntityId, + }; + }); + + const addResult = await fixEntitySuggestionCollection.createMany(junctionRecords); + createdItems = addResult.createdItems; + errorItems = addResult.errorItems; + } + + this.log.info(`Set fix entities for suggestion ${suggestionId}: removed ${removedCount}, ` + + `added ${createdItems.length}, failed ${errorItems.length}`); + + return { createdItems, errorItems, removedCount }; + } catch (error) { + this.log.error('Failed to set fix entities for suggestion', error); + throw new DataAccessError('Failed to set 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 74f7ca0f0..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('has_many', 'FixEntitySuggestion') + .addReference('has_many', 'FixEntitySuggestion', ['updatedAt'], { removeDependents: true }) .addAttribute('type', { type: Object.values(Suggestion.TYPES), required: true, 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..a6daeefae 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 @@ -986,4 +986,185 @@ describe('BaseCollection', () => { ]); }); }); + + describe('batchGetByIds', () => { + it('should successfully batch get entities by IDs', async () => { + const ids = ['ef39921f-9a02-41db-b491-02c98987d956', '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.batchGetByIds(ids); + + 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([ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ]); + }); + + it('should handle partial results with unprocessed items', async () => { + const ids = ['ef39921f-9a02-41db-b491-02c98987d956', 'ef39921f-9a02-41db-b491-02c98987d957', '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.batchGetByIds(ids); + + 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(['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 ids = ['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.batchGetByIds(ids); + + expect(result).to.deep.equal({ + data: [], + unprocessed: ['ef39921f-9a02-41db-b491-02c98987d999'], + }); + }); + + it('should throw error when ids is not provided', async () => { + await expect(baseCollectionInstance.batchGetByIds()).to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith( + 'Failed to batch get [mockEntityModel]: ids must be a non-empty array', + ); + }); + + it('should throw error when ids is not an array', async () => { + await expect(baseCollectionInstance.batchGetByIds('not-an-array')).to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith( + 'Failed to batch get [mockEntityModel]: ids must be a non-empty array', + ); + }); + + it('should throw error when ids is an empty array', async () => { + await expect(baseCollectionInstance.batchGetByIds([])).to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith( + 'Failed to batch get [mockEntityModel]: ids must be a non-empty array', + ); + }); + + it('should throw error when ids contains null values', async () => { + await expect(baseCollectionInstance.batchGetByIds(['ef39921f-9a02-41db-b491-02c98987d956', null, 'ef39921f-9a02-41db-b491-02c98987d957'])).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + }); + + it('should throw error when ids contains undefined values', async () => { + await expect(baseCollectionInstance.batchGetByIds(['ef39921f-9a02-41db-b491-02c98987d956', undefined, 'ef39921f-9a02-41db-b491-02c98987d957'])).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + }); + + it('should throw error when ids contains empty strings', async () => { + await expect(baseCollectionInstance.batchGetByIds(['ef39921f-9a02-41db-b491-02c98987d956', '', 'ef39921f-9a02-41db-b491-02c98987d957'])).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + }); + + it('should throw error when ids contains non-string values', async () => { + await expect(baseCollectionInstance.batchGetByIds(['ef39921f-9a02-41db-b491-02c98987d956', 123, 'ef39921f-9a02-41db-b491-02c98987d957'])).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + }); + + it('should handle database errors and throw DataAccessError', async () => { + const ids = ['ef39921f-9a02-41db-b491-02c98987d956']; + const error = new Error('Database connection failed'); + + mockElectroService.entities.mockEntityModel.get.returns({ + go: stub().rejects(error), + }); + + await expect(baseCollectionInstance.batchGetByIds(ids)).to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to batch get [mockEntityModel]', error); + }); + + it('should handle null records in results', async () => { + const ids = ['ef39921f-9a02-41db-b491-02c98987d956', '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.batchGetByIds(ids); + + expect(result.data).to.have.length(2); + expect(result.unprocessed).to.deep.equal([]); + }); + + it('should handle large batch sizes', async () => { + const ids = Array.from({ length: 100 }, (_, i) => `ef39921f-9a02-41db-b491-02c98987d${i.toString().padStart(3, '0')}`); + const mockRecords = ids.map((id) => ({ ...mockRecord, mockEntityModelId: id })); + + const mockElectroResult = { + data: mockRecords, + unprocessed: [], + }; + + mockElectroService.entities.mockEntityModel.get.returns({ + go: stub().resolves(mockElectroResult), + }); + + const result = await baseCollectionInstance.batchGetByIds(ids); + + 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 IDs', async () => { + const ids = ['ef39921f-9a02-41db-b491-02c98987d956', '', 'ef39921f-9a02-41db-b491-02c98987d957', null, 'ef39921f-9a02-41db-b491-02c98987d958']; + + await expect(baseCollectionInstance.batchGetByIds(ids)).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + }); + + it('should log error and throw DataAccessError on validation failure', async () => { + const ids = ['ef39921f-9a02-41db-b491-02c98987d956', 'invalid-id-format']; + + await expect(baseCollectionInstance.batchGetByIds(ids)).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + }); + }); }); 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 a724d7e8d..ad0b164fb 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 @@ -208,6 +208,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() }); }); @@ -224,16 +225,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); @@ -242,6 +250,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 deleted file mode 100644 index e859aeec1..000000000 --- a/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.collection.test.js +++ /dev/null @@ -1,244 +0,0 @@ -/* - * 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 sinonChai from 'sinon-chai'; -import { stub } from 'sinon'; - -import FixEntitySuggestion from '../../../../src/models/fix-entity-suggestion/fix-entity-suggestion.model.js'; - -import { createElectroMocks } from '../../util.js'; - -chaiUse(chaiAsPromised); -chaiUse(sinonChai); - -describe('FixEntitySuggestionCollection', () => { - let instance; - - let mockElectroService; - let mockEntityRegistry; - let mockLogger; - let model; - let schema; - - const mockRecord = { - suggestionId: 'suggestion-123', - fixEntityId: 'fix-456', - }; - - beforeEach(() => { - ({ - mockElectroService, - mockEntityRegistry, - mockLogger, - collection: instance, - model, - schema, - } = createElectroMocks(FixEntitySuggestion, mockRecord)); - }); - - describe('constructor', () => { - it('initializes the FixEntitySuggestionCollection instance correctly', () => { - expect(instance).to.be.an('object'); - expect(instance.electroService).to.equal(mockElectroService); - expect(instance.entityRegistry).to.equal(mockEntityRegistry); - expect(instance.schema).to.equal(schema); - expect(instance.log).to.equal(mockLogger); - - expect(model).to.be.an('object'); - }); - }); - - describe('allByFixEntityId', () => { - it('calls allByForeignKey with correct parameters', async () => { - const fixEntityId = 'fix-entity-123'; - - // Mock the inherited allByForeignKey method - const allByForeignKeyStub = stub().resolves([model]); - instance.allByForeignKey = allByForeignKeyStub; - - const result = await instance.allByFixEntityId(fixEntityId); - - expect(allByForeignKeyStub).to.have.been.calledOnceWith('fixEntityId', fixEntityId); - expect(result).to.deep.equal([model]); - }); - }); - - describe('allBySuggestionId', () => { - it('successfully queries by suggestionId using primary index', async () => { - const suggestionId = 'suggestion-123'; - const mockJunctionRecords = [ - { suggestionId, fixEntityId: 'fix-456' }, - { suggestionId, fixEntityId: 'fix-789' }, - ]; - - // Set up the entity query mock - const queryStub = stub().returns({ - go: stub().resolves({ data: mockJunctionRecords }), - }); - - mockElectroService.entities.fixEntitySuggestion.query.primary = queryStub; - - // Mock createInstance method - const createInstanceStub = stub() - .onFirstCall() - .returns({ id: 'instance-1' }) - .onSecondCall() - .returns({ id: 'instance-2' }); - instance.createInstance = createInstanceStub; - - const result = await instance.allBySuggestionId(suggestionId); - - expect(queryStub).to.have.been.calledWith({ suggestionId }); - expect(createInstanceStub).to.have.been.calledTwice; - expect(result).to.deep.equal([{ id: 'instance-1' }, { id: 'instance-2' }]); - }); - - it('logs and throws error when query fails', async () => { - const suggestionId = 'suggestion-123'; - const error = new Error('Database query failed'); - - const queryStub = stub().returns({ - go: stub().rejects(error), - }); - - mockElectroService.entities.fixEntitySuggestion.query.primary = queryStub; - - await expect(instance.allBySuggestionId(suggestionId)) - .to.be.rejectedWith('Database query failed'); - - expect(mockLogger.error).to.have.been.calledWith( - 'Failed to query FixEntitySuggestions by suggestionId: Database query failed', - ); - }); - }); - - describe('createRelationship', () => { - it('creates a relationship between fix entity and suggestion', async () => { - const fixEntityId = 'fix-456'; - const suggestionId = 'suggestion-123'; - const expectedData = { fixEntityId, suggestionId }; - - const createStub = stub().resolves(model); - instance.create = createStub; - - const result = await instance.createRelationship(fixEntityId, suggestionId); - - expect(createStub).to.have.been.calledOnceWith(expectedData); - expect(result).to.equal(model); - }); - }); - - describe('findRelationship', () => { - it('successfully finds an existing relationship', async () => { - const suggestionId = 'suggestion-123'; - const fixEntityId = 'fix-456'; - - const getStub = stub().returns({ - go: stub().resolves({ data: mockRecord }), - }); - - mockElectroService.entities.fixEntitySuggestion.get = getStub; - - const createInstanceStub = stub().returns(model); - instance.createInstance = createInstanceStub; - - const result = await instance.findRelationship(suggestionId, fixEntityId); - - expect(getStub).to.have.been.calledWith({ suggestionId, fixEntityId }); - expect(createInstanceStub).to.have.been.calledWith(mockRecord); - expect(result).to.equal(model); - }); - - it('returns null when relationship is not found', async () => { - const suggestionId = 'suggestion-123'; - const fixEntityId = 'fix-456'; - const notFoundError = new Error('Item not found'); - - const getStub = stub().returns({ - go: stub().rejects(notFoundError), - }); - - mockElectroService.entities.fixEntitySuggestion.get = getStub; - - const result = await instance.findRelationship(suggestionId, fixEntityId); - - expect(result).to.be.null; - }); - - it('throws error for other database errors', async () => { - const suggestionId = 'suggestion-123'; - const fixEntityId = 'fix-456'; - const error = new Error('Database connection failed'); - - const getStub = stub().returns({ - go: stub().rejects(error), - }); - - mockElectroService.entities.fixEntitySuggestion.get = getStub; - - await expect(instance.findRelationship(suggestionId, fixEntityId)) - .to.be.rejectedWith('Database connection failed'); - }); - }); - - describe('removeRelationship', () => { - it('successfully removes an existing relationship', async () => { - const suggestionId = 'suggestion-123'; - const fixEntityId = 'fix-456'; - - const deleteStub = stub().returns({ - go: stub().resolves({}), - }); - - mockElectroService.entities.fixEntitySuggestion.delete = deleteStub; - - await instance.removeRelationship(suggestionId, fixEntityId); - - expect(deleteStub).to.have.been.calledWith({ suggestionId, fixEntityId }); - }); - - it('ignores "not found" errors when removing non-existent relationship', async () => { - const suggestionId = 'suggestion-123'; - const fixEntityId = 'fix-456'; - const notFoundError = new Error('Item not found'); - - const deleteStub = stub().returns({ - go: stub().rejects(notFoundError), - }); - - mockElectroService.entities.fixEntitySuggestion.delete = deleteStub; - - // Should not throw an error - await expect(instance.removeRelationship(suggestionId, fixEntityId)) - .to.be.fulfilled; - }); - - it('throws error for other database errors during removal', async () => { - const suggestionId = 'suggestion-123'; - const fixEntityId = 'fix-456'; - const error = new Error('Database connection failed'); - - const deleteStub = stub().returns({ - go: stub().rejects(error), - }); - - mockElectroService.entities.fixEntitySuggestion.delete = deleteStub; - - await expect(instance.removeRelationship(suggestionId, fixEntityId)) - .to.be.rejectedWith('Database connection failed'); - }); - }); -}); 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 deleted file mode 100644 index 4dec784dc..000000000 --- a/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.model.test.js +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 FixEntitySuggestion from '../../../../src/models/fix-entity-suggestion/fix-entity-suggestion.model.js'; -import BaseModel from '../../../../src/models/base/base.model.js'; - -describe('FixEntitySuggestion Model', () => { - describe('class definition', () => { - it('extends BaseModel', () => { - expect(FixEntitySuggestion.prototype).to.be.instanceOf(BaseModel); - }); - - it('has the correct DEFAULT_UPDATED_BY constant', () => { - expect(FixEntitySuggestion.DEFAULT_UPDATED_BY).to.equal('spacecat'); - }); - - it('can be instantiated', () => { - // Note: In a real scenario, FixEntitySuggestion would be instantiated through the collection - // This test just verifies the class structure - expect(FixEntitySuggestion).to.be.a('function'); - expect(FixEntitySuggestion.name).to.equal('FixEntitySuggestion'); - }); - }); - - describe('junction table functionality', () => { - it('represents a many-to-many relationship between FixEntity and Suggestion', () => { - // This is more of a documentation test - the junction table allows: - // - One FixEntity to be associated with multiple Suggestions - // - One Suggestion to be associated with multiple FixEntities - expect(FixEntitySuggestion.prototype).to.be.instanceOf(BaseModel); - }); - }); -}); 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..e33e61488 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,256 @@ * 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 { 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 { 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: 'fix-123', + opportunityId: 'op-123', + 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', }; 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 = 'fix-123'; + const mockJunctionRecords = [ + { getSuggestionId: () => 'suggestion-1' }, + { getSuggestionId: () => 'suggestion-2' }, + ]; + const mockSuggestions = [ + { id: 'suggestion-1', title: 'Suggestion 1' }, + { id: 'suggestion-2', title: 'Suggestion 2' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(mockJunctionRecords), + }; + + const mockSuggestionCollection = { + batchGetByIds: stub().resolves({ + data: mockSuggestions, + unprocessed: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + mockEntityRegistry.getCollection + .withArgs('Suggestion') + .returns(mockSuggestionCollection); + + const result = await fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId); + + expect(result).to.deep.equal({ + data: mockSuggestions, + unprocessed: [], + }); + + expect(mockFixEntitySuggestionCollection.allByFixEntityId) + .to.have.been.calledOnceWith(fixEntityId); + expect(mockSuggestionCollection.batchGetByIds) + .to.have.been.calledOnceWith(['suggestion-1', 'suggestion-2']); + }); + + it('should return empty arrays when no junction records found', async () => { + const fixEntityId = 'fix-123'; + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves([]), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId); + + expect(result).to.deep.equal({ + data: [], + unprocessed: [], + }); + + 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(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to get suggestions: fixEntityId is required'); + }); + + it('should handle errors and throw DataAccessError', async () => { + const fixEntityId = 'fix-123'; + const error = new Error('Database error'); + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().rejects(error), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .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); + }); + }); + + describe('setSuggestionsByFixEntityId', () => { + it('should set suggestions for a fix entity with delta updates', async () => { + const fixEntityId = 'fix-123'; + const suggestions = ['suggestion-1', 'suggestion-2']; + + const existingJunctionRecords = [ + { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-1' }, + { getId: () => 'junction-2', getSuggestionId: () => 'suggestion-3' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(existingJunctionRecords), + removeByIds: stub().resolves(), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-3' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsByFixEntityId(fixEntityId, suggestions); + + expect(result).to.deep.equal({ + createdItems: [{ id: 'junction-3' }], + errorItems: [], + removedCount: 1, + }); + + expect(mockFixEntitySuggestionCollection.allByFixEntityId) + .to.have.been.calledOnceWith(fixEntityId); + expect(mockFixEntitySuggestionCollection.removeByIds).to.have.been.calledOnceWith(['junction-2']); + expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ + { fixEntityId, suggestionId: 'suggestion-2' }, + ]); + }); + + it('should handle suggestions as model instances', async () => { + const fixEntityId = 'fix-123'; + const suggestionModels = [ + { getId: () => 'suggestion-1' }, + { getId: () => 'suggestion-2' }, + ]; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves([]), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + const result = await fixEntityCollection + .setSuggestionsByFixEntityId(fixEntityId, suggestionModels); + + expect(result).to.deep.equal({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + removedCount: 0, + }); + + expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ + { fixEntityId, suggestionId: 'suggestion-1' }, + { fixEntityId, suggestionId: 'suggestion-2' }, + ]); + }); + + it('should throw error when fixEntityId is not provided', async () => { + await expect(fixEntityCollection.setSuggestionsByFixEntityId()) + .to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to set suggestions: fixEntityId is required'); + }); + + it('should throw error when suggestions is not an array', async () => { + const fixEntityId = 'fix-123'; + + await expect(fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, 'not-an-array')).to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Suggestions must be an array'); + }); + + it('should handle errors and throw DataAccessError', async () => { + const fixEntityId = 'fix-123'; + const suggestions = ['suggestion-1']; + const error = new Error('Database error'); + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().rejects(error), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + await expect(fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, suggestions)) + .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 fixEntityId = 'fix-123'; + const suggestions = ['suggestion-1']; + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves([]), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-1' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + await fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, suggestions); - expect(model).to.be.an('object'); + expect(mockLogger.info).to.have.been.calledWith( + `Set suggestions for fix entity ${fixEntityId}: removed 0, added 1, failed 0`, + ); }); }); }); 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..6718f841f 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,249 @@ 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 = 'suggestion-123'; + const mockJunctionRecords = [ + { getFixEntityId: () => 'fix-1' }, + { getFixEntityId: () => 'fix-2' }, + ]; + const mockFixEntities = [ + { id: 'fix-1', title: 'Fix 1' }, + { id: 'fix-2', title: 'Fix 2' }, + ]; + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().resolves(mockJunctionRecords), + }; + + const mockFixEntityCollection = { + batchGetByIds: stub().resolves({ + data: mockFixEntities, + unprocessed: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + mockEntityRegistry.getCollection + .withArgs('FixEntity') + .returns(mockFixEntityCollection); + + const result = await instance.getFixEntitiesBySuggestionId(suggestionId); + + expect(result).to.deep.equal({ + data: mockFixEntities, + unprocessed: [], + }); + + expect(mockFixEntitySuggestionCollection.allBySuggestionId) + .to.have.been.calledOnceWith(suggestionId); + expect(mockFixEntityCollection.batchGetByIds).to.have.been.calledOnceWith(['fix-1', 'fix-2']); + }); + + it('should return empty arrays when no junction records found', async () => { + const suggestionId = 'suggestion-123'; + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().resolves([]), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + const result = await instance.getFixEntitiesBySuggestionId(suggestionId); + + expect(result).to.deep.equal({ + data: [], + unprocessed: [], + }); + + 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(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to get fix entities: suggestionId is required'); + }); + + it('should handle errors and throw DataAccessError', async () => { + const suggestionId = 'suggestion-123'; + const error = new Error('Database error'); + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().rejects(error), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .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); + }); + }); + + describe('setFixEntitiesBySuggestionId', () => { + it('should set fix entities for a suggestion with delta updates', async () => { + const suggestionId = 'suggestion-123'; + const fixEntities = ['fix-1', 'fix-2']; + + const existingJunctionRecords = [ + { getId: () => 'junction-1', getFixEntityId: () => 'fix-1' }, + { getId: () => 'junction-2', getFixEntityId: () => 'fix-3' }, + ]; + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().resolves(existingJunctionRecords), + removeByIds: stub().resolves(), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-3' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + + expect(result).to.deep.equal({ + createdItems: [{ id: 'junction-3' }], + errorItems: [], + removedCount: 1, + }); + + expect(mockFixEntitySuggestionCollection.allBySuggestionId) + .to.have.been.calledOnceWith(suggestionId); + expect(mockFixEntitySuggestionCollection.removeByIds).to.have.been.calledOnceWith(['junction-2']); + expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ + { suggestionId, fixEntityId: 'fix-2' }, + ]); + }); + + it('should handle fix entities as model instances', async () => { + const suggestionId = 'suggestion-123'; + const fixEntityModels = [ + { getId: () => 'fix-1' }, + { getId: () => 'fix-2' }, + ]; + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().resolves([]), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntityModels); + + expect(result).to.deep.equal({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + removedCount: 0, + }); + + expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ + { suggestionId, fixEntityId: 'fix-1' }, + { suggestionId, fixEntityId: 'fix-2' }, + ]); + }); + + it('should throw error when suggestionId is not provided', async () => { + await expect(instance.setFixEntitiesBySuggestionId()).to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to set fix entities: suggestionId is required'); + }); + + it('should throw error when fixEntities is not an array', async () => { + const suggestionId = 'suggestion-123'; + + await expect(instance.setFixEntitiesBySuggestionId(suggestionId, 'not-an-array')).to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Fix entities must be an array'); + }); + + it('should handle errors and throw DataAccessError', async () => { + const suggestionId = 'suggestion-123'; + const fixEntities = ['fix-1']; + const error = new Error('Database error'); + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().rejects(error), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + await expect(instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities)) + .to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to set fix entities for suggestion', error); + }); + + it('should log info about the operation results', async () => { + const suggestionId = 'suggestion-123'; + const fixEntities = ['fix-1']; + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().resolves([]), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-1' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + + expect(mockLogger.info).to.have.been.calledWith( + `Set fix entities for suggestion ${suggestionId}: removed 0, added 1, failed 0`, + ); + }); + + it('should handle mixed input types (strings and models)', async () => { + const suggestionId = 'suggestion-123'; + const mixedInput = [ + 'fix-1', // string + { getId: () => 'fix-2' }, // model + ]; + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().resolves([]), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestion') + .returns(mockFixEntitySuggestionCollection); + + const result = await instance.setFixEntitiesBySuggestionId(suggestionId, mixedInput); + + expect(result).to.deep.equal({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + removedCount: 0, + }); + + expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ + { suggestionId, fixEntityId: 'fix-1' }, + { suggestionId, fixEntityId: 'fix-2' }, + ]); + }); + }); }); From aa91ad66fc5473f431ff7c2dc5ec1890bfd006f3 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Thu, 2 Oct 2025 11:51:52 +0530 Subject: [PATCH 04/12] fix: failing ITs --- .../src/models/base/schema.builder.js | 14 +++++++++----- .../src/models/base/schema.js | 3 ++- .../fix-entity-suggestion.schema.js | 2 +- .../test/it/opportunity/opportunity.test.js | 3 +++ 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/base/schema.builder.js b/packages/spacecat-shared-data-access/src/models/base/schema.builder.js index 0982f5e5e..b0caf468e 100755 --- a/packages/spacecat-shared-data-access/src/models/base/schema.builder.js +++ b/packages/spacecat-shared-data-access/src/models/base/schema.builder.js @@ -356,6 +356,8 @@ class SchemaBuilder { * BELONGS_TO references. * @param {boolean} [options.removeDependents=false] - Whether to remove dependent entities * on delete. Only applies to HAS_MANY and HAS_ONE references. + * @param {boolean} [options.skipForeignKeyIndex=false] - Whether to skip + * adding a foreign key index. Only applies to BELONGS_TO references. * @returns {SchemaBuilder} Returns this builder for method chaining. * @throws {SchemaBuilderError} If type or entityName are invalid. */ @@ -395,11 +397,13 @@ class SchemaBuilder { ) => (reference.options.required ? isValidUUID(value) : !value || isValidUUID(value)), }); - this.#internalAddIndex( - { composite: [decapitalize(foreignKeyName)] }, - { composite: isNonEmptyArray(sortKeys) ? sortKeys : ['updatedAt'] }, - Schema.INDEX_TYPES.BELONGS_TO, - ); + if (!options.skipForeignKeyIndex) { + this.#internalAddIndex( + { composite: [decapitalize(foreignKeyName)] }, + { composite: isNonEmptyArray(sortKeys) ? sortKeys : ['updatedAt'] }, + Schema.INDEX_TYPES.BELONGS_TO, + ); + } } this.references.push(Reference.fromJSON(reference)); 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 d5d3b62a9..bc62cb146 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,8 @@ 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)); + return pk?.facets.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.schema.js b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js index 608994a65..3c236c270 100644 --- 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 @@ -23,7 +23,7 @@ Indexes Doc: https://electrodb.dev/en/modeling/indexes/ const schema = new SchemaBuilder(FixEntitySuggestion, FixEntitySuggestionCollection) .withPrimaryPartitionKeys(['suggestionId']) .withPrimarySortKeys(['fixEntityId']) - .addReference('belongs_to', 'Suggestion') + .addReference('belongs_to', 'Suggestion', [], { skipForeignKeyIndex: true }) .addReference('belongs_to', 'FixEntity'); export default schema.build(); 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..e5e8a5321 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 @@ -242,6 +242,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()); From c9d7e78fdd91ab3b06569ad1582bc11bbb1ee12e Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Thu, 2 Oct 2025 13:24:26 +0530 Subject: [PATCH 05/12] fix: pk error --- packages/spacecat-shared-data-access/src/models/base/schema.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 bc62cb146..05456ee5f 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,8 @@ class Schema { const allKeys = [...(pk?.facets || []), ...(sk?.facets || [])]; // check if all keys in the index are in the sort keys - return pk?.facets.every((key) => subKeyNames.includes(key)) + const pkKeys = Array.isArray(pk?.facets) ? pk.facets : []; + return pkKeys.every((key) => subKeyNames.includes(key)) && subKeyNames.every((key) => allKeys.includes(key)); }); From 3e08683564a6163f32e30a20fa448e0cd1919be4 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Thu, 2 Oct 2025 21:31:20 +0530 Subject: [PATCH 06/12] fix: adds ITs --- .../src/models/base/base.collection.js | 45 ++ .../src/models/base/schema.builder.js | 14 +- .../src/models/base/schema.js | 2 +- .../fix-entity-suggestion.schema.js | 2 +- .../models/fix-entity-suggestion/index.d.ts | 3 +- .../fix-entity/fix-entity.collection.js | 30 +- .../suggestion/suggestion.collection.js | 26 +- .../fix-entity-suggestions.fixture.js | 17 + .../test/fixtures/index.fixtures.js | 2 + .../fix-entity-suggestion.test.js | 523 ++++++++++++++++++ .../unit/models/base/base.collection.test.js | 199 ++++++- .../fix-entity-suggestion.collection.test.js | 262 +++++++++ .../fix-entity/fix-entity.collection.test.js | 29 +- .../suggestion/suggestion.collection.test.js | 31 +- 14 files changed, 1138 insertions(+), 47 deletions(-) create mode 100644 packages/spacecat-shared-data-access/test/fixtures/fix-entity-suggestions.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/it/fix-entity-suggestion/fix-entity-suggestion.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.collection.test.js 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 3c0b61f22..5aff216b2 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 @@ -625,6 +625,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/schema.builder.js b/packages/spacecat-shared-data-access/src/models/base/schema.builder.js index b0caf468e..0982f5e5e 100755 --- a/packages/spacecat-shared-data-access/src/models/base/schema.builder.js +++ b/packages/spacecat-shared-data-access/src/models/base/schema.builder.js @@ -356,8 +356,6 @@ class SchemaBuilder { * BELONGS_TO references. * @param {boolean} [options.removeDependents=false] - Whether to remove dependent entities * on delete. Only applies to HAS_MANY and HAS_ONE references. - * @param {boolean} [options.skipForeignKeyIndex=false] - Whether to skip - * adding a foreign key index. Only applies to BELONGS_TO references. * @returns {SchemaBuilder} Returns this builder for method chaining. * @throws {SchemaBuilderError} If type or entityName are invalid. */ @@ -397,13 +395,11 @@ class SchemaBuilder { ) => (reference.options.required ? isValidUUID(value) : !value || isValidUUID(value)), }); - if (!options.skipForeignKeyIndex) { - this.#internalAddIndex( - { composite: [decapitalize(foreignKeyName)] }, - { composite: isNonEmptyArray(sortKeys) ? sortKeys : ['updatedAt'] }, - Schema.INDEX_TYPES.BELONGS_TO, - ); - } + this.#internalAddIndex( + { composite: [decapitalize(foreignKeyName)] }, + { composite: isNonEmptyArray(sortKeys) ? sortKeys : ['updatedAt'] }, + Schema.INDEX_TYPES.BELONGS_TO, + ); } this.references.push(Reference.fromJSON(reference)); 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 05456ee5f..a2bd5c19a 100644 --- a/packages/spacecat-shared-data-access/src/models/base/schema.js +++ b/packages/spacecat-shared-data-access/src/models/base/schema.js @@ -348,7 +348,7 @@ class Schema { requiredKeys: subset, }); - log.debug(`Created accessors for index [${indexName}] with keys [${subset.join(', ')}]`); + log.debug(`Created accessors ${keyNamesToMethodName(subset, 'allBy')} and ${keyNamesToMethodName(subset, 'findBy')} for index [${indexName}] with keys [${subset.join(', ')}] for entity ${entity.clazz.name}`); }); }); 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 index 3c236c270..4d4b52c26 100644 --- 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 @@ -23,7 +23,7 @@ Indexes Doc: https://electrodb.dev/en/modeling/indexes/ const schema = new SchemaBuilder(FixEntitySuggestion, FixEntitySuggestionCollection) .withPrimaryPartitionKeys(['suggestionId']) .withPrimarySortKeys(['fixEntityId']) - .addReference('belongs_to', 'Suggestion', [], { skipForeignKeyIndex: true }) + .addReference('belongs_to', 'Suggestion', ['fixEntityId']) .addReference('belongs_to', 'FixEntity'); 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 index 58e152e63..0caa429f9 100644 --- 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 @@ -22,6 +22,7 @@ export interface FixEntitySuggestion extends BaseModel { } export interface FixEntitySuggestionCollection extends BaseCollection { - allByFixEntityId(fixEntityId: string): Promise; + allByFixEntityIdAndSuggestionId(fixEntityId: string, suggestionId: string): Promise; allBySuggestionId(suggestionId: string): Promise; + allByFixEntityId(fixEntityId: string): Promise; } 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 f5b9eaa5c..ef38ca1be 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 @@ -41,7 +41,7 @@ class FixEntityCollection extends BaseCollection { } try { - const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestion'); + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); const fixEntitySuggestions = await fixEntitySuggestionCollection .allByFixEntityId(fixEntityId); @@ -52,7 +52,7 @@ class FixEntityCollection extends BaseCollection { return { data: [], unprocessed: [] }; } - const suggestionCollection = this.entityRegistry.getCollection('Suggestion'); + const suggestionCollection = this.entityRegistry.getCollection('SuggestionCollection'); return await suggestionCollection.batchGetByIds(suggestionIds).then((result) => ({ data: result.data, @@ -75,7 +75,7 @@ class FixEntityCollection extends BaseCollection { * model instances. * @returns {Promise<{createdItems: Array, errorItems: Array, removedCount: number}>} - A promise * that resolves to an object containing: - * - createdItems: Array of created FixEntitySuggestion junction records + * - 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 fixEntityId is not provided or if the @@ -95,7 +95,7 @@ class FixEntityCollection extends BaseCollection { } try { - const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestion'); + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); // Get current suggestion IDs const currentSuggestionIds = new Set(); @@ -117,12 +117,20 @@ class FixEntityCollection extends BaseCollection { (rel) => !newSuggestionIds.has(rel.getSuggestionId()), ); - // Find what to add (new but not existing) + // Find what to add (new but not existing), removing duplicates + const seenSuggestionIds = new Set(); const toAdd = suggestions.filter((suggestion) => { const suggestionId = typeof suggestion === 'string' ? suggestion : suggestion.getId(); - return !currentSuggestionIds.has(suggestionId); + + // Skip if already seen (duplicate) or already exists + if (seenSuggestionIds.has(suggestionId) || currentSuggestionIds.has(suggestionId)) { + return false; + } + + seenSuggestionIds.add(suggestionId); + return true; }); let removedCount = 0; @@ -131,8 +139,14 @@ class FixEntityCollection extends BaseCollection { // Remove relationships that are no longer needed if (toRemove.length > 0) { - const removeIds = toRemove.map((rel) => rel.getId()); - await fixEntitySuggestionCollection.removeByIds(removeIds); + const removeIds = toRemove.map((rel) => rel.getSuggestionId()); + await fixEntitySuggestionCollection.removeByIndexKeys( + removeIds.map((id) => ( + { + suggestionId: id, + fixEntityId, + })), + ); removedCount = removeIds.length; } 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 1f2a69ed0..9be3dfbbc 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 @@ -74,7 +74,7 @@ class SuggestionCollection extends BaseCollection { } try { - const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestion'); + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); // Get all junction records for this suggestion const fixEntitySuggestions = await fixEntitySuggestionCollection @@ -88,7 +88,7 @@ class SuggestionCollection extends BaseCollection { } // Get the FixEntity collection from the entity registry - const fixEntityCollection = this.entityRegistry.getCollection('FixEntity'); + const fixEntityCollection = this.entityRegistry.getCollection('FixEntityCollection'); // Get all fix entities by their IDs using batch get return await fixEntityCollection.batchGetByIds(fixEntityIds).then((result) => ({ @@ -133,7 +133,7 @@ class SuggestionCollection extends BaseCollection { } try { - const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestion'); + const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); // Get current fix entity IDs const currentFixEntityIds = new Set(); @@ -155,12 +155,20 @@ class SuggestionCollection extends BaseCollection { (rel) => !newFixEntityIds.has(rel.getFixEntityId()), ); - // Find what to add (new but not existing) + // Find what to add (new but not existing), removing duplicates + const seenFixEntityIds = new Set(); const toAdd = fixEntities.filter((fixEntity) => { const fixEntityId = typeof fixEntity === 'string' ? fixEntity : fixEntity.getId(); - return !currentFixEntityIds.has(fixEntityId); + + // Skip if already seen (duplicate) or already exists + if (seenFixEntityIds.has(fixEntityId) || currentFixEntityIds.has(fixEntityId)) { + return false; + } + + seenFixEntityIds.add(fixEntityId); + return true; }); let removedCount = 0; @@ -169,8 +177,12 @@ class SuggestionCollection extends BaseCollection { // Remove relationships that are no longer needed if (toRemove.length > 0) { - const removeIds = toRemove.map((rel) => rel.getId()); - await fixEntitySuggestionCollection.removeByIds(removeIds); + const removeIds = toRemove.map((rel) => rel.getFixEntityId()); + await fixEntitySuggestionCollection.removeByIndexKeys(removeIds.map((id) => ( + { + suggestionId, + fixEntityId: id, + }))); removedCount = removeIds.length; } 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/index.fixtures.js b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js index cd73407c6..587cce6ed 100644 --- a/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js +++ b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js @@ -28,6 +28,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..b915bf030 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/fix-entity-suggestion/fix-entity-suggestion.test.js @@ -0,0 +1,523 @@ +/* + * 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 suggestionIds = [ + sampleData.suggestions[0].getId(), + sampleData.suggestions[1].getId(), + sampleData.suggestions[2].getId(), + ]; + + const result = await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + suggestionIds, + ); + + 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(suggestionIds[index]); + }); + }); + + 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 result = await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + 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 initialSuggestionIds = [ + sampleData.suggestions[0].getId(), + sampleData.suggestions[1].getId(), + ]; + + // First, set initial suggestions + await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + initialSuggestionIds, + ); + + // Then update with different suggestions + const newSuggestionIds = [ + sampleData.suggestions[1].getId(), // Keep this one + sampleData.suggestions[2].getId(), // Add this one + sampleData.suggestions[3].getId(), // Add this one + ]; + + const result = await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + newSuggestionIds, + ); + + 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.data).to.be.an('array').with.length(3); + + const finalSuggestionIds = finalSuggestions.data.map((s) => s.getId()).sort(); + expect(finalSuggestionIds).to.deep.equal(newSuggestionIds.sort()); + }); + + it('sets empty array to remove all suggestions from a fix entity', async () => { + const fixEntity = sampleData.fixEntities[1]; + + // First add some suggestions + await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + [sampleData.suggestions[0].getId(), sampleData.suggestions[1].getId()], + ); + + // Then remove all by setting empty array + const result = await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + [], + ); + + 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 suggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); + expect(suggestions.data).to.be.an('array').with.length(0); + }); + + it('throws error when fixEntityId is not provided', async () => { + await expect( + FixEntity.setSuggestionsByFixEntityId(null, []), + ).to.be.rejectedWith('Failed to set suggestions: fixEntityId is required'); + }); + + it('sets fix entities for a suggestion using fix entity IDs', async () => { + const suggestion = sampleData.suggestions[0]; + const fixEntityIds = [ + sampleData.fixEntities[0].getId(), + sampleData.fixEntities[1].getId(), + ]; + + const result = await Suggestion.setFixEntitiesBySuggestionId( + suggestion.getId(), + fixEntityIds, + ); + + 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.getSuggestionId()).to.equal(suggestion.getId()); + expect(item.getFixEntityId()).to.equal(fixEntityIds[index]); + }); + }); + + it('sets fix entities for a suggestion using fix entity objects', async () => { + const suggestion = sampleData.suggestions[1]; + const fixEntities = [ + sampleData.fixEntities[0], + sampleData.fixEntities[2], + ]; + + const result = await Suggestion.setFixEntitiesBySuggestionId( + suggestion.getId(), + fixEntities, + ); + + 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.getSuggestionId()).to.equal(suggestion.getId()); + expect(item.getFixEntityId()).to.equal(fixEntities[index].getId()); + }); + }); + + it('updates fix entities for a suggestion (removes old, adds new)', async () => { + const suggestion = sampleData.suggestions[2]; + const initialFixEntityIds = [ + sampleData.fixEntities[0].getId(), + sampleData.fixEntities[1].getId(), + ]; + + // First, set initial fix entities + await Suggestion.setFixEntitiesBySuggestionId( + suggestion.getId(), + initialFixEntityIds, + ); + + // Then update with different fix entities + const newFixEntityIds = [ + sampleData.fixEntities[1].getId(), // Keep this one + sampleData.fixEntities[2].getId(), // Add this one + ]; + + const result = await Suggestion.setFixEntitiesBySuggestionId( + suggestion.getId(), + newFixEntityIds, + ); + + expect(result).to.be.an('object'); + expect(result.createdItems).to.be.an('array').with.length(1); // Added 1 new + expect(result.errorItems).to.be.an('array').with.length(0); + expect(result.removedCount).to.equal(1); // Removed 1 old + + // Verify final state + const finalFixEntities = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); + expect(finalFixEntities.data).to.be.an('array').with.length(2); + + const finalFixEntityIds = finalFixEntities.data.map((f) => f.getId()).sort(); + expect(finalFixEntityIds).to.deep.equal(newFixEntityIds.sort()); + }); + + it('throws error when suggestionId is not provided', async () => { + await expect( + Suggestion.setFixEntitiesBySuggestionId(null, []), + ).to.be.rejectedWith('Failed to set fix entities: suggestionId is required'); + }); + + it('gets all suggestions for a fix entity', async () => { + const fixEntity = sampleData.fixEntities[0]; + const suggestionIds = [ + sampleData.suggestions[0].getId(), + sampleData.suggestions[1].getId(), + ]; + + // First set up the relationships + await FixEntity.setSuggestionsByFixEntityId(fixEntity.getId(), suggestionIds); + + // Then retrieve them + const result = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); + + expect(result).to.be.an('object'); + expect(result.data).to.be.an('array').with.length(2); + expect(result.unprocessed).to.be.an('array').with.length(0); + + // Verify the suggestions are correct + const retrievedIds = result.data.map((s) => s.getId()).sort(); + expect(retrievedIds).to.deep.equal(suggestionIds.sort()); + + // Verify they are proper suggestion objects + result.data.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('object'); + expect(result.data).to.be.an('array').with.length(0); + expect(result.unprocessed).to.be.an('array').with.length(0); + }); + + it('throws error when fixEntityId is not provided', async () => { + await expect( + FixEntity.getSuggestionsByFixEntityId(null), + ).to.be.rejectedWith('Failed to get suggestions: fixEntityId is required'); + }); + + 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 + await Suggestion.setFixEntitiesBySuggestionId(suggestion.getId(), fixEntityIds); + + // Then retrieve them + const result = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); + + expect(result).to.be.an('object'); + expect(result.data).to.be.an('array').with.length(2); + expect(result.unprocessed).to.be.an('array').with.length(0); + + // Verify the fix entities are correct + const retrievedIds = result.data.map((f) => f.getId()).sort(); + expect(retrievedIds).to.deep.equal(fixEntityIds.sort()); + + // Verify they are proper fix entity objects + result.data.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('object'); + expect(result.data).to.be.an('array').with.length(0); + expect(result.unprocessed).to.be.an('array').with.length(0); + }); + + it('throws error when suggestionId is not provided', async () => { + await expect( + Suggestion.getFixEntitiesBySuggestionId(null), + ).to.be.rejectedWith('Failed to get fix entities: suggestionId is required'); + }); + + it('creates junction records directly', async () => { + const junctionData = [ + { + suggestionId: sampleData.suggestions[0].getId(), + fixEntityId: sampleData.fixEntities[0].getId(), + }, + { + suggestionId: sampleData.suggestions[1].getId(), + fixEntityId: sampleData.fixEntities[1].getId(), + }, + ]; + + 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(); + + // Create a junction record first + await FixEntitySuggestion.create({ + suggestionId, + fixEntityId: sampleData.fixEntities[0].getId(), + }); + + 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 fixEntityId = sampleData.fixEntities[0].getId(); + + // Create a junction record first + await FixEntitySuggestion.create({ + suggestionId: sampleData.suggestions[0].getId(), + fixEntityId, + }); + + 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 mixedIds = [ + sampleData.suggestions[0].getId(), // Valid + 'invalid-suggestion-id', // Invalid + sampleData.suggestions[1].getId(), // Valid + ]; + + // This should not throw an error, but should handle validation at the junction level + const result = await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + mixedIds, + ); + + // 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 duplicateIds = [ + sampleData.suggestions[0].getId(), + sampleData.suggestions[1].getId(), + sampleData.suggestions[0].getId(), // Duplicate + ]; + + const result = await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + duplicateIds, + ); + + // 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 suggestionIds = [ + sampleData.suggestions[0].getId(), + sampleData.suggestions[1].getId(), + ]; + + // Set suggestions first time + const result1 = await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + suggestionIds, + ); + + 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.setSuggestionsByFixEntityId( + fixEntity.getId(), + suggestionIds, + ); + + expect(result2.createdItems).to.be.an('array').with.length(0); + expect(result2.removedCount).to.equal(0); + + // Verify final state + const suggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); + expect(suggestions.data).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]; + + // Set relationship from FixEntity side + await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + [suggestion.getId()], + ); + + // Verify from Suggestion side + const fixEntitiesFromSuggestion = await Suggestion.getFixEntitiesBySuggestionId( + suggestion.getId(), + ); + expect(fixEntitiesFromSuggestion.data).to.be.an('array').with.length(1); + expect(fixEntitiesFromSuggestion.data[0].getId()).to.equal(fixEntity.getId()); + + // Set additional relationship from Suggestion side + await Suggestion.setFixEntitiesBySuggestionId( + suggestion.getId(), + [fixEntity.getId(), sampleData.fixEntities[1].getId()], + ); + + // Verify from FixEntity side + const suggestionsFromFixEntity1 = await FixEntity.getSuggestionsByFixEntityId( + fixEntity.getId(), + ); + const suggestionsFromFixEntity2 = await FixEntity.getSuggestionsByFixEntityId( + sampleData.fixEntities[1].getId(), + ); + + expect(suggestionsFromFixEntity1.data).to.be.an('array').with.length(1); + expect(suggestionsFromFixEntity1.data[0].getId()).to.equal(suggestion.getId()); + + expect(suggestionsFromFixEntity2.data).to.be.an('array').with.length(1); + expect(suggestionsFromFixEntity2.data[0].getId()).to.equal(suggestion.getId()); + }); +}); 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 a6daeefae..e882c7134 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'; @@ -1167,4 +1167,201 @@ describe('BaseCollection', () => { await expect(baseCollectionInstance.batchGetByIds(ids)).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); }); }); + + 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/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..260925a05 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.collection.test.js @@ -0,0 +1,262 @@ +/* + * 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', + }; + + 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 = 'suggestion-123'; + const expectedRecords = [ + { suggestionId, fixEntityId: 'fix-1' }, + { suggestionId, fixEntityId: 'fix-2' }, + ]; + + 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 is required'); + + await expect(collection.allBySuggestionId('')) + .to.be.rejectedWith('suggestionId is required'); + + await expect(collection.allBySuggestionId(undefined)) + .to.be.rejectedWith('suggestionId is required'); + }); + + it('should handle empty results', async () => { + const suggestionId = 'suggestion-nonexistent'; + collection.allByIndexKeys.resolves([]); + + const result = await collection.allBySuggestionId(suggestionId); + + expect(result).to.be.an('array').that.is.empty; + }); + }); + + describe('allByFixEntityId', () => { + it('should get all junction records for a fix entity ID', async () => { + const fixEntityId = 'fix-123'; + const expectedRecords = [ + { suggestionId: 'suggestion-1', fixEntityId }, + { suggestionId: 'suggestion-2', fixEntityId }, + ]; + + 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 = 'fix-nonexistent'; + 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' }, + { suggestionId: 'suggestion-1', fixEntityId: 'fix-2' }, + ]; + + 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' }, + { id: 'junction-2', suggestionId: 'suggestion-1', fixEntityId: 'fix-2' }, + ], + 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('suggestion-123')) + .to.be.rejectedWith('Database connection failed'); + }); + + it('should propagate errors from allByIndexKeys in allByFixEntityId', async () => { + const error = new Error('Index not found'); + collection.allByIndexKeys.rejects(error); + + await expect(collection.allByFixEntityId('fix-123')) + .to.be.rejectedWith('Index not found'); + }); + }); + + describe('performance considerations', () => { + it('should use efficient index queries for large datasets', async () => { + const suggestionId = 'suggestion-with-many-fixes'; + const largeResultSet = Array.from({ length: 1000 }, (_, i) => ({ + suggestionId, + fixEntityId: `fix-${i}`, + })); + + 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 = 'fix-with-many-suggestions'; + + // Mock paginated results + collection.allByIndexKeys.resolves([ + { suggestionId: 'suggestion-1', fixEntityId }, + { suggestionId: 'suggestion-2', fixEntityId }, + ]); + + 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/fix-entity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/fix-entity/fix-entity.collection.test.js index e33e61488..0876122a0 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 @@ -70,10 +70,10 @@ describe('FixEntityCollection', () => { }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); mockEntityRegistry.getCollection - .withArgs('Suggestion') + .withArgs('SuggestionCollection') .returns(mockSuggestionCollection); const result = await fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId); @@ -93,10 +93,11 @@ describe('FixEntityCollection', () => { const fixEntityId = 'fix-123'; const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId); @@ -122,10 +123,11 @@ describe('FixEntityCollection', () => { const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().rejects(error), + removeByIndexKeys: stub().resolves(), }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); await expect(fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId)) @@ -147,6 +149,7 @@ describe('FixEntityCollection', () => { const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves(existingJunctionRecords), removeByIds: stub().resolves(), + removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ createdItems: [{ id: 'junction-3' }], errorItems: [], @@ -154,7 +157,7 @@ describe('FixEntityCollection', () => { }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection @@ -168,7 +171,12 @@ describe('FixEntityCollection', () => { expect(mockFixEntitySuggestionCollection.allByFixEntityId) .to.have.been.calledOnceWith(fixEntityId); - expect(mockFixEntitySuggestionCollection.removeByIds).to.have.been.calledOnceWith(['junction-2']); + expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([ + { + suggestionId: 'suggestion-3', + fixEntityId, + }, + ]); expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ { fixEntityId, suggestionId: 'suggestion-2' }, ]); @@ -183,6 +191,7 @@ describe('FixEntityCollection', () => { const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], errorItems: [], @@ -190,7 +199,7 @@ describe('FixEntityCollection', () => { }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection @@ -228,10 +237,11 @@ describe('FixEntityCollection', () => { const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().rejects(error), + removeByIndexKeys: stub().resolves(), }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); await expect(fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, suggestions)) @@ -245,6 +255,7 @@ describe('FixEntityCollection', () => { const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ createdItems: [{ id: 'junction-1' }], errorItems: [], @@ -252,7 +263,7 @@ describe('FixEntityCollection', () => { }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); await fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, suggestions); 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 6718f841f..b19ceb245 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 @@ -118,6 +118,7 @@ describe('SuggestionCollection', () => { const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves(mockJunctionRecords), + removeByIndexKeys: stub().resolves(), }; const mockFixEntityCollection = { @@ -128,10 +129,10 @@ describe('SuggestionCollection', () => { }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); mockEntityRegistry.getCollection - .withArgs('FixEntity') + .withArgs('FixEntityCollection') .returns(mockFixEntityCollection); const result = await instance.getFixEntitiesBySuggestionId(suggestionId); @@ -150,10 +151,11 @@ describe('SuggestionCollection', () => { const suggestionId = 'suggestion-123'; const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); const result = await instance.getFixEntitiesBySuggestionId(suggestionId); @@ -178,10 +180,11 @@ describe('SuggestionCollection', () => { const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().rejects(error), + removeByIndexKeys: stub().resolves(), }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); await expect(instance.getFixEntitiesBySuggestionId(suggestionId)) @@ -203,6 +206,7 @@ describe('SuggestionCollection', () => { const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves(existingJunctionRecords), removeByIds: stub().resolves(), + removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ createdItems: [{ id: 'junction-3' }], errorItems: [], @@ -210,7 +214,7 @@ describe('SuggestionCollection', () => { }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); @@ -223,7 +227,10 @@ describe('SuggestionCollection', () => { expect(mockFixEntitySuggestionCollection.allBySuggestionId) .to.have.been.calledOnceWith(suggestionId); - expect(mockFixEntitySuggestionCollection.removeByIds).to.have.been.calledOnceWith(['junction-2']); + expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([{ + suggestionId, + fixEntityId: 'fix-3', + }]); expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ { suggestionId, fixEntityId: 'fix-2' }, ]); @@ -238,6 +245,7 @@ describe('SuggestionCollection', () => { const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], errorItems: [], @@ -245,7 +253,7 @@ describe('SuggestionCollection', () => { }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntityModels); @@ -281,10 +289,11 @@ describe('SuggestionCollection', () => { const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().rejects(error), + removeByIndexKeys: stub().resolves(), }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); await expect(instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities)) @@ -298,6 +307,7 @@ describe('SuggestionCollection', () => { const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ createdItems: [{ id: 'junction-1' }], errorItems: [], @@ -305,7 +315,7 @@ describe('SuggestionCollection', () => { }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); @@ -324,6 +334,7 @@ describe('SuggestionCollection', () => { const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), + removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], errorItems: [], @@ -331,7 +342,7 @@ describe('SuggestionCollection', () => { }; mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestion') + .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); const result = await instance.setFixEntitiesBySuggestionId(suggestionId, mixedInput); From 269712bbde8da2e65b2acdfd16815e013b132cbd Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Fri, 3 Oct 2025 16:00:09 +0530 Subject: [PATCH 07/12] fix: adds validation, removes duplication, add more ITs, and util functions --- .../src/models/base/base.collection.js | 30 +- .../src/models/base/index.d.ts | 2 +- .../fix-entity-suggestion.schema.js | 2 +- .../models/fix-entity-suggestion/index.d.ts | 1 - .../fix-entity/fix-entity.collection.js | 161 ++++---- .../src/models/fix-entity/fix-entity.model.js | 6 + .../src/models/suggestion/index.d.ts | 2 - .../suggestion/suggestion.collection.js | 149 +++---- .../src/util/util.js | 15 + .../fix-entity-suggestion.test.js | 68 ++- .../test/it/fix-entity/fix-entity.test.js | 29 ++ .../test/it/suggestion/suggestion.test.js | 43 +- .../unit/models/base/base.collection.test.js | 126 +++--- .../fix-entity/fix-entity.collection.test.js | 389 +++++++++++++++--- .../fix-entity/fix-entity.model.test.js | 116 ++++++ .../suggestion/suggestion.collection.test.js | 249 +++++++---- 16 files changed, 954 insertions(+), 434 deletions(-) create mode 100644 packages/spacecat-shared-data-access/test/unit/models/fix-entity/fix-entity.model.test.js 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 5aff216b2..9d10fe6b4 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, @@ -389,26 +389,12 @@ class BaseCollection { * @throws {DataAccessError} - Throws an error if the IDs are not provided or if the batch * operation fails. */ - async batchGetByIds(ids) { - if (!isNonEmptyArray(ids)) { - const message = `Failed to batch get [${this.entityName}]: ids must be a non-empty array`; - this.log.error(message); - throw new DataAccessError(message); - } - - // Validate all IDs - ids.forEach((id, index) => { - try { - guardId(this.idName, id, this.entityName); - } catch (error) { - throw new DataAccessError(`Invalid ID at index ${index}: ${error.message}`, this, error); - } - }); + async batchGetByKeys(keys) { + guardArray('keys', keys, this.entityName, 'any'); try { - // Use ElectroDB's batch get const result = await this.entity.get( - ids.map((id) => ({ [this.idName]: id })), + keys, ).go(); // Process found entities @@ -416,15 +402,15 @@ class BaseCollection { .map((record) => this.#createInstance(record)) .filter((entity) => entity !== null); - // Extract unprocessed IDs + // Extract unprocessed keys const unprocessed = result.unprocessed - ? result.unprocessed.map((item) => item[this.idName]) + ? result.unprocessed.map((item) => item) : []; return { data, unprocessed }; } catch (error) { - this.log.error(`Failed to batch get [${this.entityName}]`, error); - throw new DataAccessError('Failed to batch get entities', this, error); + this.log.error(`Failed to batch get by keys [${this.entityName}]`, error); + throw new DataAccessError('Failed to batch get by keys', this, error); } } 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 e881a877f..b0344b8fc 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 @@ -49,7 +49,7 @@ export interface BaseCollection { _saveMany(items: T[]): Promise; all(sortKeys?: object, options?: QueryOptions): Promise; allByIndexKeys(keys: object, options?: QueryOptions): Promise; - batchGetByIds(ids: string[]): Promise<{ data: T[]; unprocessed: string[] }>; + batchGetByKeys(keys: object[]): 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/fix-entity-suggestion/fix-entity-suggestion.schema.js b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js index 4d4b52c26..608994a65 100644 --- 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 @@ -23,7 +23,7 @@ Indexes Doc: https://electrodb.dev/en/modeling/indexes/ const schema = new SchemaBuilder(FixEntitySuggestion, FixEntitySuggestionCollection) .withPrimaryPartitionKeys(['suggestionId']) .withPrimarySortKeys(['fixEntityId']) - .addReference('belongs_to', 'Suggestion', ['fixEntityId']) + .addReference('belongs_to', 'Suggestion') .addReference('belongs_to', 'FixEntity'); 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 index 0caa429f9..01d82b7e4 100644 --- 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 @@ -22,7 +22,6 @@ export interface FixEntitySuggestion extends BaseModel { } export interface FixEntitySuggestionCollection extends BaseCollection { - allByFixEntityIdAndSuggestionId(fixEntityId: string, suggestionId: string): Promise; allBySuggestionId(suggestionId: string): Promise; allByFixEntityId(fixEntityId: string): Promise; } 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 ef38ca1be..8dd833e4b 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 @@ -11,6 +11,8 @@ */ import BaseCollection from '../base/base.collection.js'; import DataAccessError from '../../errors/data-access.error.js'; +import { guardId, guardArray } from '../../util/guards.js'; +import { resolveUpdates } from '../../util/util.js'; /** * FixEntityCollection - A collection class responsible for managing FixEntities. @@ -21,24 +23,46 @@ import DataAccessError from '../../errors/data-access.error.js'; * @extends BaseCollection */ class FixEntityCollection extends BaseCollection { + /** + * Gets all Suggestions associated with an array of FixEntitySuggestion junction records. + * This is a helper method that takes junction records and retrieves the actual + * suggestion entities. + * + * @async + * @param {Array} fixEntitySuggestions - An array of FixEntitySuggestion junction records. + * @returns {Promise} - A promise that resolves to an array of Suggestion models + * @throws {DataAccessError} - Throws an error if the fixEntitySuggestions are not provided + * or if the query fails. + */ + async getSuggestionsByFixEntitySuggestions(fixEntitySuggestions) { + guardArray('fixEntitySuggestions', fixEntitySuggestions, 'FixEntityCollection', 'any'); + if (fixEntitySuggestions.length === 0) { + return []; + } + + try { + 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 suggestions', error); + throw new DataAccessError('Failed to get suggestions for fix entity suggestions', this, error); + } + } + /** * Gets all suggestions associated with a specific FixEntity. * * @async * @param {string} fixEntityId - The ID of the FixEntity. - * @returns {Promise<{data: Array, unprocessed: Array}>} - A promise that resolves to an - * object containing: - * - data: Array of found Suggestion models - * - unprocessed: Array of suggestion IDs that couldn't be processed + * @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) { - if (!fixEntityId) { - const message = 'Failed to get suggestions: fixEntityId is required'; - this.log.error(message); - throw new DataAccessError(message); - } + guardId('fixEntityId', fixEntityId, 'FixEntityCollection'); try { const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); @@ -46,18 +70,7 @@ class FixEntityCollection extends BaseCollection { const fixEntitySuggestions = await fixEntitySuggestionCollection .allByFixEntityId(fixEntityId); - const suggestionIds = fixEntitySuggestions.map((record) => record.getSuggestionId()); - - if (suggestionIds.length === 0) { - return { data: [], unprocessed: [] }; - } - - const suggestionCollection = this.entityRegistry.getCollection('SuggestionCollection'); - - return await suggestionCollection.batchGetByIds(suggestionIds).then((result) => ({ - data: result.data, - unprocessed: result.unprocessed, - })); + return this.getSuggestionsByFixEntitySuggestions(fixEntitySuggestions); } 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); @@ -71,8 +84,7 @@ class FixEntityCollection extends BaseCollection { * * @async * @param {string} fixEntityId - The ID of the FixEntity. - * @param {Array} suggestions - An array of suggestion IDs (strings) or suggestion - * model instances. + * @param {Array} suggestionIds - An array of suggestion IDs (strings). * @returns {Promise<{createdItems: Array, errorItems: Array, removedCount: number}>} - A promise * that resolves to an object containing: * - createdItems: Array of created FixEntitySuggestionCollection junction records @@ -81,91 +93,58 @@ class FixEntityCollection extends BaseCollection { * @throws {DataAccessError} - Throws an error if the fixEntityId is not provided or if the * operation fails. */ - async setSuggestionsByFixEntityId(fixEntityId, suggestions) { - if (!fixEntityId) { - const message = 'Failed to set suggestions: fixEntityId is required'; - this.log.error(message); - throw new DataAccessError(message); - } - - if (!Array.isArray(suggestions)) { - const message = 'Suggestions must be an array'; - this.log.error(message); - throw new DataAccessError(message); - } + async setSuggestionsByFixEntityId(fixEntityId, suggestionIds) { + guardId('fixEntityId', fixEntityId, 'FixEntityCollection'); + guardArray('suggestionIds', suggestionIds, 'FixEntityCollection'); try { const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); - // Get current suggestion IDs - const currentSuggestionIds = new Set(); const existingRelationships = await fixEntitySuggestionCollection .allByFixEntityId(fixEntityId); - existingRelationships.forEach((rel) => currentSuggestionIds.add(rel.getSuggestionId())); - - // Get new suggestion IDs - const newSuggestionIds = new Set(); - suggestions.forEach((suggestion) => { - const suggestionId = typeof suggestion === 'string' - ? suggestion - : suggestion.getId(); - newSuggestionIds.add(suggestionId); - }); - - // Find what to remove (existing but not in new) - const toRemove = existingRelationships.filter( - (rel) => !newSuggestionIds.has(rel.getSuggestionId()), - ); - - // Find what to add (new but not existing), removing duplicates - const seenSuggestionIds = new Set(); - const toAdd = suggestions.filter((suggestion) => { - const suggestionId = typeof suggestion === 'string' - ? suggestion - : suggestion.getId(); - - // Skip if already seen (duplicate) or already exists - if (seenSuggestionIds.has(suggestionId) || currentSuggestionIds.has(suggestionId)) { - return false; - } - - seenSuggestionIds.add(suggestionId); - return true; - }); - let removedCount = 0; - let createdItems = []; - let errorItems = []; + // Extract existing suggestion IDs from relationship objects + const existingSuggestionIds = existingRelationships.map((rel) => rel.getSuggestionId()); + + const { toDelete, toCreate } = resolveUpdates(existingSuggestionIds, suggestionIds); + + let removePromise; + let createPromise; - // Remove relationships that are no longer needed - if (toRemove.length > 0) { - const removeIds = toRemove.map((rel) => rel.getSuggestionId()); - await fixEntitySuggestionCollection.removeByIndexKeys( - removeIds.map((id) => ( + if (toDelete.length > 0) { + removePromise = fixEntitySuggestionCollection.removeByIndexKeys( + toDelete.map((suggestionId) => ( { - suggestionId: id, + suggestionId, fixEntityId, })), ); - removedCount = removeIds.length; } - // Add new relationships - if (toAdd.length > 0) { - const junctionRecords = toAdd.map((suggestion) => { - const suggestionId = typeof suggestion === 'string' - ? suggestion - : suggestion.getId(); - - return { + if (toCreate.length > 0) { + createPromise = fixEntitySuggestionCollection.createMany(toCreate.map((suggestionId) => ( + { 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); + } - const addResult = await fixEntitySuggestionCollection.createMany(junctionRecords); - createdItems = addResult.createdItems; - errorItems = addResult.errorItems; + 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}, ` 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/suggestion/index.d.ts b/packages/spacecat-shared-data-access/src/models/suggestion/index.d.ts index 681a197d7..19f670328 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 @@ -20,7 +20,6 @@ export interface Suggestion extends BaseModel { getRank(): number; getStatus(): string; getType(): string; - getFixEntitySuggestions(): Promise; setData(data: object): Suggestion; setKpiDeltas(kpiDeltas: object): Suggestion; setOpportunityId(opportunityId: string): Suggestion; @@ -30,7 +29,6 @@ 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; 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 9be3dfbbc..4028ab22f 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 @@ -13,6 +13,8 @@ import BaseCollection from '../base/base.collection.js'; import DataAccessError from '../../errors/data-access.error.js'; import Suggestion from './suggestion.model.js'; +import { guardId, guardArray } from '../../util/guards.js'; +import { resolveUpdates } from '../../util/util.js'; /** * SuggestionCollection - A collection class responsible for managing Suggestion entities. @@ -59,42 +61,29 @@ class SuggestionCollection extends BaseCollection { * * @async * @param {string} suggestionId - The ID of the Suggestion. - * @returns {Promise<{data: Array, unprocessed: Array}>} - A promise that resolves to an - * object containing: - * - data: Array of found FixEntity models - * - unprocessed: Array of fix entity IDs that couldn't be processed + * @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) { - if (!suggestionId) { - const message = 'Failed to get fix entities: suggestionId is required'; - this.log.error(message); - throw new DataAccessError(message); - } + 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); - // Extract fix entity IDs from junction records - const fixEntityIds = fixEntitySuggestions.map((record) => record.getFixEntityId()); - - if (fixEntityIds.length === 0) { - return { data: [], unprocessed: [] }; + if (fixEntitySuggestions.length === 0) { + return []; } - // Get the FixEntity collection from the entity registry - const fixEntityCollection = this.entityRegistry.getCollection('FixEntityCollection'); - - // Get all fix entities by their IDs using batch get - return await fixEntityCollection.batchGetByIds(fixEntityIds).then((result) => ({ - data: result.data, - unprocessed: result.unprocessed, - })); + 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); @@ -109,8 +98,7 @@ class SuggestionCollection extends BaseCollection { * * @async * @param {string} suggestionId - The ID of the Suggestion. - * @param {Array} fixEntities - An array of fix entity IDs (strings) or fix entity - * model instances. + * @param {Array} fixEntityIds - An array of fix entity IDs (strings). * @returns {Promise<{createdItems: Array, errorItems: Array, removedCount: number}>} - A promise * that resolves to an object containing: * - createdItems: Array of created FixEntitySuggestion junction records @@ -119,89 +107,58 @@ class SuggestionCollection extends BaseCollection { * @throws {DataAccessError} - Throws an error if the suggestionId is not provided or if the * operation fails. */ - async setFixEntitiesBySuggestionId(suggestionId, fixEntities) { - if (!suggestionId) { - const message = 'Failed to set fix entities: suggestionId is required'; - this.log.error(message); - throw new DataAccessError(message); - } - - if (!Array.isArray(fixEntities)) { - const message = 'Fix entities must be an array'; - this.log.error(message); - throw new DataAccessError(message); - } + async setFixEntitiesBySuggestionId(suggestionId, fixEntityIds) { + guardId('suggestionId', suggestionId, 'SuggestionCollection'); + guardArray('fixEntityIds', fixEntityIds, 'SuggestionCollection'); try { const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); - // Get current fix entity IDs - const currentFixEntityIds = new Set(); - const fixEntitySuggestions = await fixEntitySuggestionCollection + const existingRelationships = await fixEntitySuggestionCollection .allBySuggestionId(suggestionId); - fixEntitySuggestions.forEach((rel) => currentFixEntityIds.add(rel.getFixEntityId())); - - // Get new fix entity IDs - const newFixEntityIds = new Set(); - fixEntities.forEach((fixEntity) => { - const fixEntityId = typeof fixEntity === 'string' - ? fixEntity - : fixEntity.getId(); - newFixEntityIds.add(fixEntityId); - }); - - // Find what to remove (existing but not in new) - const toRemove = fixEntitySuggestions.filter( - (rel) => !newFixEntityIds.has(rel.getFixEntityId()), - ); - - // Find what to add (new but not existing), removing duplicates - const seenFixEntityIds = new Set(); - const toAdd = fixEntities.filter((fixEntity) => { - const fixEntityId = typeof fixEntity === 'string' - ? fixEntity - : fixEntity.getId(); - - // Skip if already seen (duplicate) or already exists - if (seenFixEntityIds.has(fixEntityId) || currentFixEntityIds.has(fixEntityId)) { - return false; - } - - seenFixEntityIds.add(fixEntityId); - return true; - }); + + // Extract existing fix entity IDs from relationship objects + const existingFixEntityIds = existingRelationships.map((rel) => rel.getFixEntityId()); + + const { toDelete, toCreate } = resolveUpdates(existingFixEntityIds, fixEntityIds); + + let removePromise; + let createPromise; + const deleteKeys = toDelete.map((fixEntityId) => ( + { + suggestionId, + fixEntityId, + })); + const createKeys = toCreate.map((fixEntityId) => ( + { + suggestionId, + fixEntityId, + })); + + if (toDelete.length > 0) { + removePromise = fixEntitySuggestionCollection.removeByIndexKeys(deleteKeys); + } + + if (toCreate.length > 0) { + createPromise = fixEntitySuggestionCollection.createMany(createKeys); + } + + const [removeResult, createResult] = await Promise.allSettled([removePromise, createPromise]); let removedCount = 0; let createdItems = []; let errorItems = []; - - // Remove relationships that are no longer needed - if (toRemove.length > 0) { - const removeIds = toRemove.map((rel) => rel.getFixEntityId()); - await fixEntitySuggestionCollection.removeByIndexKeys(removeIds.map((id) => ( - { - suggestionId, - fixEntityId: id, - }))); - removedCount = removeIds.length; + if (removeResult.status === 'fulfilled') { + removedCount = deleteKeys.length; + } else { + this.log.error('Remove operation failed:', removeResult.reason); } - // Add new relationships - if (toAdd.length > 0) { - const junctionRecords = toAdd.map((fixEntity) => { - const fixEntityId = typeof fixEntity === 'string' - ? fixEntity - : fixEntity.getId(); - - return { - suggestionId, - fixEntityId, - }; - }); - - const addResult = await fixEntitySuggestionCollection.createMany(junctionRecords); - createdItems = addResult.createdItems; - errorItems = addResult.errorItems; + 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 fix entities for suggestion ${suggestionId}: removed ${removedCount}, ` 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/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 index b915bf030..88ab62510 100644 --- 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 @@ -75,8 +75,8 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { it('sets suggestions for a fix entity using suggestion objects', async () => { const fixEntity = sampleData.fixEntities[1]; const suggestions = [ - sampleData.suggestions[3], - sampleData.suggestions[4], + sampleData.suggestions[3].getId(), + sampleData.suggestions[4].getId(), ]; const result = await FixEntity.setSuggestionsByFixEntityId( @@ -92,7 +92,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // 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()); + expect(item.getSuggestionId()).to.equal(suggestions[index]); }); }); @@ -128,9 +128,9 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify final state const finalSuggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(finalSuggestions.data).to.be.an('array').with.length(3); + expect(finalSuggestions).to.be.an('array').with.length(3); - const finalSuggestionIds = finalSuggestions.data.map((s) => s.getId()).sort(); + const finalSuggestionIds = finalSuggestions.map((s) => s.getId()).sort(); expect(finalSuggestionIds).to.deep.equal(newSuggestionIds.sort()); }); @@ -156,13 +156,13 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify no suggestions remain const suggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(suggestions.data).to.be.an('array').with.length(0); + expect(suggestions).to.be.an('array').with.length(0); }); it('throws error when fixEntityId is not provided', async () => { await expect( FixEntity.setSuggestionsByFixEntityId(null, []), - ).to.be.rejectedWith('Failed to set suggestions: fixEntityId is required'); + ).to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntityId must be a valid UUID'); }); it('sets fix entities for a suggestion using fix entity IDs', async () => { @@ -192,8 +192,8 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { it('sets fix entities for a suggestion using fix entity objects', async () => { const suggestion = sampleData.suggestions[1]; const fixEntities = [ - sampleData.fixEntities[0], - sampleData.fixEntities[2], + sampleData.fixEntities[0].getId(), + sampleData.fixEntities[2].getId(), ]; const result = await Suggestion.setFixEntitiesBySuggestionId( @@ -209,7 +209,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify the relationships were created result.createdItems.forEach((item, index) => { expect(item.getSuggestionId()).to.equal(suggestion.getId()); - expect(item.getFixEntityId()).to.equal(fixEntities[index].getId()); + expect(item.getFixEntityId()).to.equal(fixEntities[index]); }); }); @@ -244,16 +244,16 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify final state const finalFixEntities = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); - expect(finalFixEntities.data).to.be.an('array').with.length(2); + expect(finalFixEntities).to.be.an('array').with.length(2); - const finalFixEntityIds = finalFixEntities.data.map((f) => f.getId()).sort(); + const finalFixEntityIds = finalFixEntities.map((f) => f.getId()).sort(); expect(finalFixEntityIds).to.deep.equal(newFixEntityIds.sort()); }); it('throws error when suggestionId is not provided', async () => { await expect( Suggestion.setFixEntitiesBySuggestionId(null, []), - ).to.be.rejectedWith('Failed to set fix entities: suggestionId is required'); + ).to.be.rejectedWith('Validation failed in SuggestionCollection: suggestionId must be a valid UUID'); }); it('gets all suggestions for a fix entity', async () => { @@ -269,16 +269,14 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Then retrieve them const result = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(result).to.be.an('object'); - expect(result.data).to.be.an('array').with.length(2); - expect(result.unprocessed).to.be.an('array').with.length(0); + expect(result).to.be.an('array').with.length(2); // Verify the suggestions are correct - const retrievedIds = result.data.map((s) => s.getId()).sort(); + const retrievedIds = result.map((s) => s.getId()).sort(); expect(retrievedIds).to.deep.equal(suggestionIds.sort()); // Verify they are proper suggestion objects - result.data.forEach((suggestion) => { + result.forEach((suggestion) => { expect(suggestion).to.be.an('object'); expect(suggestion.getId()).to.be.a('string'); expect(suggestion.getOpportunityId()).to.be.a('string'); @@ -292,15 +290,13 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { const result = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(result).to.be.an('object'); - expect(result.data).to.be.an('array').with.length(0); - expect(result.unprocessed).to.be.an('array').with.length(0); + 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('Failed to get suggestions: fixEntityId is required'); + ).to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntityId must be a valid UUID'); }); it('gets all fix entities for a suggestion', async () => { @@ -316,16 +312,14 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Then retrieve them const result = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); - expect(result).to.be.an('object'); - expect(result.data).to.be.an('array').with.length(2); - expect(result.unprocessed).to.be.an('array').with.length(0); + expect(result).to.be.an('array').with.length(2); // Verify the fix entities are correct - const retrievedIds = result.data.map((f) => f.getId()).sort(); + const retrievedIds = result.map((f) => f.getId()).sort(); expect(retrievedIds).to.deep.equal(fixEntityIds.sort()); // Verify they are proper fix entity objects - result.data.forEach((fixEntity) => { + result.forEach((fixEntity) => { expect(fixEntity).to.be.an('object'); expect(fixEntity.getId()).to.be.a('string'); expect(fixEntity.getOpportunityId()).to.be.a('string'); @@ -339,15 +333,13 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { const result = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); - expect(result).to.be.an('object'); - expect(result.data).to.be.an('array').with.length(0); - expect(result.unprocessed).to.be.an('array').with.length(0); + 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('Failed to get fix entities: suggestionId is required'); + ).to.be.rejectedWith('Validation failed in SuggestionCollection: suggestionId must be a valid UUID'); }); it('creates junction records directly', async () => { @@ -480,7 +472,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify final state const suggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(suggestions.data).to.be.an('array').with.length(2); + expect(suggestions).to.be.an('array').with.length(2); }); it('maintains consistency when setting relationships from both sides', async () => { @@ -497,8 +489,8 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { const fixEntitiesFromSuggestion = await Suggestion.getFixEntitiesBySuggestionId( suggestion.getId(), ); - expect(fixEntitiesFromSuggestion.data).to.be.an('array').with.length(1); - expect(fixEntitiesFromSuggestion.data[0].getId()).to.equal(fixEntity.getId()); + expect(fixEntitiesFromSuggestion).to.be.an('array').with.length(1); + expect(fixEntitiesFromSuggestion[0].getId()).to.equal(fixEntity.getId()); // Set additional relationship from Suggestion side await Suggestion.setFixEntitiesBySuggestionId( @@ -514,10 +506,10 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { sampleData.fixEntities[1].getId(), ); - expect(suggestionsFromFixEntity1.data).to.be.an('array').with.length(1); - expect(suggestionsFromFixEntity1.data[0].getId()).to.equal(suggestion.getId()); + expect(suggestionsFromFixEntity1).to.be.an('array').with.length(1); + expect(suggestionsFromFixEntity1[0].getId()).to.equal(suggestion.getId()); - expect(suggestionsFromFixEntity2.data).to.be.an('array').with.length(1); - expect(suggestionsFromFixEntity2.data[0].getId()).to.equal(suggestion.getId()); + expect(suggestionsFromFixEntity2).to.be.an('array').with.length(1); + expect(suggestionsFromFixEntity2[0].getId()).to.equal(suggestion.getId()); }); }); 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..999b4f828 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'); @@ -123,4 +131,25 @@ 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 suggestionIds = [ + sampleData.suggestions[0].getId(), + sampleData.suggestions[1].getId(), + ]; + + await FixEntity.setSuggestionsByFixEntityId(fixEntity.getId(), suggestionIds); + + // Test the model method + const suggestions = await fixEntity.getSuggestions(); + + expect(suggestions).to.be.an('array').with.length(2); + suggestions.forEach((suggestion) => { + checkSuggestion(suggestion); + expect(suggestionIds).to.include(suggestion.getId()); + }); + }); }); 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..7887d20c0 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,11 +17,11 @@ 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); @@ -29,7 +29,7 @@ describe('Suggestion IT', async () => { let sampleData; let Suggestion; - before(async () => { + beforeEach(async () => { sampleData = await seedDatabase(); const dataAccess = getDataAccess(); @@ -254,4 +254,43 @@ 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 + await Suggestion.setFixEntitiesBySuggestionId(suggestion.getId(), fixEntityIds); + + // Test the single suggestion method + const fixEntities = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); + + expect(fixEntities).to.be.an('array').with.length(2); + fixEntities.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 e882c7134..2af392571 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 @@ -987,9 +987,12 @@ describe('BaseCollection', () => { }); }); - describe('batchGetByIds', () => { - it('should successfully batch get entities by IDs', async () => { - const ids = ['ef39921f-9a02-41db-b491-02c98987d956', 'ef39921f-9a02-41db-b491-02c98987d957']; + 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' }, @@ -1004,21 +1007,22 @@ describe('BaseCollection', () => { go: stub().resolves(mockElectroResult), }); - const result = await baseCollectionInstance.batchGetByIds(ids); + 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([ - { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, - { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, - ]); + expect(mockElectroService.entities.mockEntityModel.get).to.have.been.calledOnceWith(keys); }); it('should handle partial results with unprocessed items', async () => { - const ids = ['ef39921f-9a02-41db-b491-02c98987d956', 'ef39921f-9a02-41db-b491-02c98987d957', 'ef39921f-9a02-41db-b491-02c98987d958']; + 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' }, @@ -1033,19 +1037,19 @@ describe('BaseCollection', () => { go: stub().resolves(mockElectroResult), }); - const result = await baseCollectionInstance.batchGetByIds(ids); + 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(['ef39921f-9a02-41db-b491-02c98987d958']); + 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 ids = ['ef39921f-9a02-41db-b491-02c98987d999']; + const keys = [{ mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d999' }]; const mockElectroResult = { data: [], @@ -1056,65 +1060,75 @@ describe('BaseCollection', () => { go: stub().resolves(mockElectroResult), }); - const result = await baseCollectionInstance.batchGetByIds(ids); + const result = await baseCollectionInstance.batchGetByKeys(keys); expect(result).to.deep.equal({ data: [], - unprocessed: ['ef39921f-9a02-41db-b491-02c98987d999'], + unprocessed: [{ mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d999' }], }); }); - it('should throw error when ids is not provided', async () => { - await expect(baseCollectionInstance.batchGetByIds()).to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith( - 'Failed to batch get [mockEntityModel]: ids must be a non-empty array', - ); + it('should throw error when keys is not provided', async () => { + await expect(baseCollectionInstance.batchGetByKeys()).to.be.rejectedWith(DataAccessError); }); - it('should throw error when ids is not an array', async () => { - await expect(baseCollectionInstance.batchGetByIds('not-an-array')).to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith( - 'Failed to batch get [mockEntityModel]: ids must be a non-empty array', - ); + 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 ids is an empty array', async () => { - await expect(baseCollectionInstance.batchGetByIds([])).to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith( - 'Failed to batch get [mockEntityModel]: ids must be a non-empty array', - ); + it('should throw error when keys is an empty array', async () => { + await expect(baseCollectionInstance.batchGetByKeys([])).to.be.rejectedWith(DataAccessError); }); - it('should throw error when ids contains null values', async () => { - await expect(baseCollectionInstance.batchGetByIds(['ef39921f-9a02-41db-b491-02c98987d956', null, 'ef39921f-9a02-41db-b491-02c98987d957'])).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + 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 ids contains undefined values', async () => { - await expect(baseCollectionInstance.batchGetByIds(['ef39921f-9a02-41db-b491-02c98987d956', undefined, 'ef39921f-9a02-41db-b491-02c98987d957'])).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + 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 ids contains empty strings', async () => { - await expect(baseCollectionInstance.batchGetByIds(['ef39921f-9a02-41db-b491-02c98987d956', '', 'ef39921f-9a02-41db-b491-02c98987d957'])).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + 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 ids contains non-string values', async () => { - await expect(baseCollectionInstance.batchGetByIds(['ef39921f-9a02-41db-b491-02c98987d956', 123, 'ef39921f-9a02-41db-b491-02c98987d957'])).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + 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 ids = ['ef39921f-9a02-41db-b491-02c98987d956']; + 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.batchGetByIds(ids)).to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Failed to batch get [mockEntityModel]', 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 ids = ['ef39921f-9a02-41db-b491-02c98987d956', 'ef39921f-9a02-41db-b491-02c98987d957']; + 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' }, @@ -1129,15 +1143,18 @@ describe('BaseCollection', () => { go: stub().resolves(mockElectroResult), }); - const result = await baseCollectionInstance.batchGetByIds(ids); + 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 ids = Array.from({ length: 100 }, (_, i) => `ef39921f-9a02-41db-b491-02c98987d${i.toString().padStart(3, '0')}`); - const mockRecords = ids.map((id) => ({ ...mockRecord, mockEntityModelId: id })); + 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, @@ -1148,23 +1165,32 @@ describe('BaseCollection', () => { go: stub().resolves(mockElectroResult), }); - const result = await baseCollectionInstance.batchGetByIds(ids); + 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 IDs', async () => { - const ids = ['ef39921f-9a02-41db-b491-02c98987d956', '', 'ef39921f-9a02-41db-b491-02c98987d957', null, 'ef39921f-9a02-41db-b491-02c98987d958']; + 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.batchGetByIds(ids)).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + await expect(baseCollectionInstance.batchGetByKeys(keys)).to.be.rejectedWith(DataAccessError); }); it('should log error and throw DataAccessError on validation failure', async () => { - const ids = ['ef39921f-9a02-41db-b491-02c98987d956', 'invalid-id-format']; + const keys = [ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { invalidKey: 'invalid-format' }, + ]; - await expect(baseCollectionInstance.batchGetByIds(ids)).to.be.rejectedWith(DataAccessError, 'Invalid ID at index 1'); + await expect(baseCollectionInstance.batchGetByKeys(keys)).to.be.rejectedWith(DataAccessError); }); }); 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 0876122a0..b5b522f90 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 @@ -11,7 +11,7 @@ */ import { expect } from 'chai'; -import { stub, restore } from 'sinon'; +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'; @@ -23,8 +23,8 @@ describe('FixEntityCollection', () => { let mockLogger; const mockRecord = { - fixEntityId: 'fix-123', - opportunityId: 'op-123', + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', + opportunityId: '123e4567-e89b-12d3-a456-426614174001', type: 'SEO', status: 'PENDING', changeDetails: { field: 'title', oldValue: 'Old', newValue: 'New' }, @@ -48,7 +48,7 @@ describe('FixEntityCollection', () => { describe('getSuggestionsByFixEntityId', () => { it('should get suggestions for a fix entity', async () => { - const fixEntityId = 'fix-123'; + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; const mockJunctionRecords = [ { getSuggestionId: () => 'suggestion-1' }, { getSuggestionId: () => 'suggestion-2' }, @@ -63,10 +63,11 @@ describe('FixEntityCollection', () => { }; const mockSuggestionCollection = { - batchGetByIds: stub().resolves({ + batchGetByKeys: stub().resolves({ data: mockSuggestions, unprocessed: [], }), + idName: 'suggestionId', }; mockEntityRegistry.getCollection @@ -78,19 +79,19 @@ describe('FixEntityCollection', () => { const result = await fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId); - expect(result).to.deep.equal({ - data: mockSuggestions, - unprocessed: [], - }); + expect(result).to.deep.equal(mockSuggestions); expect(mockFixEntitySuggestionCollection.allByFixEntityId) .to.have.been.calledOnceWith(fixEntityId); - expect(mockSuggestionCollection.batchGetByIds) - .to.have.been.calledOnceWith(['suggestion-1', 'suggestion-2']); + 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 = 'fix-123'; + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves([]), removeByIndexKeys: stub().resolves(), @@ -102,10 +103,7 @@ describe('FixEntityCollection', () => { const result = await fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId); - expect(result).to.deep.equal({ - data: [], - unprocessed: [], - }); + expect(result).to.deep.equal([]); expect(mockFixEntitySuggestionCollection.allByFixEntityId) .to.have.been.calledOnceWith(fixEntityId); @@ -113,12 +111,11 @@ describe('FixEntityCollection', () => { it('should throw error when fixEntityId is not provided', async () => { await expect(fixEntityCollection.getSuggestionsByFixEntityId()) - .to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Failed to get suggestions: fixEntityId is required'); + .to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntityId must be a valid UUID'); }); it('should handle errors and throw DataAccessError', async () => { - const fixEntityId = 'fix-123'; + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; const error = new Error('Database error'); const mockFixEntitySuggestionCollection = { @@ -136,9 +133,74 @@ describe('FixEntityCollection', () => { }); }); + describe('getSuggestionsByFixEntitySuggestions', () => { + it('should get suggestions from fix entity suggestions', async () => { + const fixEntitySuggestions = [ + { getSuggestionId: () => 'suggestion-1' }, + { getSuggestionId: () => 'suggestion-2' }, + ]; + const mockSuggestions = [ + { id: 'suggestion-1', title: 'Suggestion 1' }, + { id: 'suggestion-2', title: 'Suggestion 2' }, + ]; + + const mockSuggestionCollection = { + batchGetByKeys: stub().resolves({ + data: mockSuggestions, + unprocessed: [], + }), + idName: 'suggestionId', + }; + + mockEntityRegistry.getCollection + .withArgs('SuggestionCollection') + .returns(mockSuggestionCollection); + + const result = await fixEntityCollection + .getSuggestionsByFixEntitySuggestions(fixEntitySuggestions); + + expect(result).to.deep.equal(mockSuggestions); + expect(mockSuggestionCollection.batchGetByKeys) + .to.have.been.calledOnceWith([ + { suggestionId: 'suggestion-1' }, + { suggestionId: 'suggestion-2' }, + ]); + }); + + it('should return empty array when no fix entity suggestions provided', async () => { + const result = await fixEntityCollection.getSuggestionsByFixEntitySuggestions([]); + expect(result).to.deep.equal([]); + }); + + it('should throw error when fixEntitySuggestions is not an array', async () => { + await expect(fixEntityCollection.getSuggestionsByFixEntitySuggestions('not-an-array')) + .to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntitySuggestions must be an array'); + }); + + it('should handle error in batchGetByKeys', async () => { + const fixEntitySuggestions = [ + { getSuggestionId: () => 'suggestion-1' }, + ]; + + const mockSuggestionCollection = { + batchGetByKeys: stub().rejects(new Error('Batch get failed')), + idName: 'suggestionId', + }; + + mockEntityRegistry.getCollection + .withArgs('SuggestionCollection') + .returns(mockSuggestionCollection); + + await expect(fixEntityCollection.getSuggestionsByFixEntitySuggestions(fixEntitySuggestions)) + .to.be.rejectedWith(DataAccessError, 'Failed to get suggestions for fix entity suggestions'); + + expect(mockLogger.error).to.have.been.calledWith('Failed to get suggestions for fix entity suggestions', sinon.match.instanceOf(Error)); + }); + }); + describe('setSuggestionsByFixEntityId', () => { it('should set suggestions for a fix entity with delta updates', async () => { - const fixEntityId = 'fix-123'; + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; const suggestions = ['suggestion-1', 'suggestion-2']; const existingJunctionRecords = [ @@ -149,7 +211,9 @@ describe('FixEntityCollection', () => { const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves(existingJunctionRecords), removeByIds: stub().resolves(), - removeByIndexKeys: stub().resolves(), + removeByIndexKeys: stub().resolves([ + { id: 'junction-2' }, + ]), createMany: stub().resolves({ createdItems: [{ id: 'junction-3' }], errorItems: [], @@ -182,13 +246,213 @@ describe('FixEntityCollection', () => { ]); }); - it('should handle suggestions as model instances', async () => { - const fixEntityId = 'fix-123'; - const suggestionModels = [ - { getId: () => 'suggestion-1' }, - { getId: () => 'suggestion-2' }, + it('should throw error when fixEntityId is not provided', async () => { + await expect(fixEntityCollection.setSuggestionsByFixEntityId()) + .to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntityId must be a valid UUID'); + }); + + it('should throw error when suggestions is not an array', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + + await expect(fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, 'not-an-array')) + .to.be.rejectedWith('Validation failed in FixEntityCollection: suggestionIds must be an array'); + }); + + it('should handle errors and throw DataAccessError', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const suggestions = ['suggestion-1']; + const error = new Error('Database error'); + + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().rejects(error), + removeByIndexKeys: stub().resolves(), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + await expect(fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, suggestions)) + .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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const suggestions = ['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.setSuggestionsByFixEntityId(fixEntityId, suggestions); + + expect(mockLogger.info).to.have.been.calledWith( + `Set suggestions for fix entity ${fixEntityId}: removed 0, added 1, failed 0`, + ); + }); + + it('should handle remove operation failure gracefully', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const suggestions = ['suggestion-1', 'suggestion-2']; + + 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 + .setSuggestionsByFixEntityId(fixEntityId, suggestions); + + 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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const suggestions = ['suggestion-1', 'suggestion-2']; + + 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 + .setSuggestionsByFixEntityId(fixEntityId, suggestions); + + 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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const suggestions = ['suggestion-1', 'suggestion-2']; + + 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 + .setSuggestionsByFixEntityId(fixEntityId, suggestions); + + 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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const suggestions = []; + + 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 + .setSuggestionsByFixEntityId(fixEntityId, suggestions); + + expect(result).to.deep.equal({ + createdItems: [], + errorItems: [], + removedCount: 2, + }); + + expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([ + { suggestionId: 'suggestion-1', fixEntityId }, + { suggestionId: 'suggestion-2', fixEntityId }, + ]); + expect(mockFixEntitySuggestionCollection.createMany).to.not.have.been.called; + }); + + it('should handle no existing relationships (create all)', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const suggestions = ['suggestion-1', 'suggestion-2']; + const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves([]), removeByIndexKeys: stub().resolves(), @@ -203,7 +467,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsByFixEntityId(fixEntityId, suggestionModels); + .setSuggestionsByFixEntityId(fixEntityId, suggestions); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], @@ -211,53 +475,59 @@ describe('FixEntityCollection', () => { removedCount: 0, }); + expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.not.have.been.called; expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ { fixEntityId, suggestionId: 'suggestion-1' }, { fixEntityId, suggestionId: 'suggestion-2' }, ]); }); - it('should throw error when fixEntityId is not provided', async () => { - await expect(fixEntityCollection.setSuggestionsByFixEntityId()) - .to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Failed to set suggestions: fixEntityId is required'); - }); - - it('should throw error when suggestions is not an array', async () => { - const fixEntityId = 'fix-123'; - - await expect(fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, 'not-an-array')).to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Suggestions must be an array'); - }); - - it('should handle errors and throw DataAccessError', async () => { - const fixEntityId = 'fix-123'; - const suggestions = ['suggestion-1']; - const error = new Error('Database error'); + it('should handle duplicate suggestion IDs in input', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const suggestions = ['suggestion-1', 'suggestion-1', 'suggestion-2', 'suggestion-2']; const mockFixEntitySuggestionCollection = { - allByFixEntityId: stub().rejects(error), + allByFixEntityId: stub().resolves([]), removeByIndexKeys: stub().resolves(), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + errorItems: [], + }), }; mockEntityRegistry.getCollection .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - await expect(fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, suggestions)) - .to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Failed to set suggestions for fix entity', error); + const result = await fixEntityCollection + .setSuggestionsByFixEntityId(fixEntityId, suggestions); + + 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([ + { fixEntityId, suggestionId: 'suggestion-1' }, + { fixEntityId, suggestionId: 'suggestion-2' }, + ]); }); - it('should log info about the operation results', async () => { - const fixEntityId = 'fix-123'; + it('should handle undefined promises (no operations needed)', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; const suggestions = ['suggestion-1']; + const existingJunctionRecords = [ + { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-1' }, + ]; + const mockFixEntitySuggestionCollection = { - allByFixEntityId: stub().resolves([]), + allByFixEntityId: stub().resolves(existingJunctionRecords), removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ - createdItems: [{ id: 'junction-1' }], + createdItems: [], errorItems: [], }), }; @@ -266,11 +536,18 @@ describe('FixEntityCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - await fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, suggestions); + const result = await fixEntityCollection + .setSuggestionsByFixEntityId(fixEntityId, suggestions); - expect(mockLogger.info).to.have.been.calledWith( - `Set suggestions for fix entity ${fixEntityId}: removed 0, added 1, failed 0`, - ); + 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; }); }); }); 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/suggestion/suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/suggestion/suggestion.collection.test.js index b19ceb245..393dc5954 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,7 +15,7 @@ import { expect, use as chaiUse } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; -import { stub, restore } from 'sinon'; +import sinon, { stub, restore } from 'sinon'; import Suggestion from '../../../../src/models/suggestion/suggestion.model.js'; import DataAccessError from '../../../../src/errors/data-access.error.js'; @@ -106,14 +106,14 @@ describe('SuggestionCollection', () => { describe('getFixEntitiesBySuggestionId', () => { it('should get fix entities for a suggestion', async () => { - const suggestionId = 'suggestion-123'; + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; const mockJunctionRecords = [ - { getFixEntityId: () => 'fix-1' }, - { getFixEntityId: () => 'fix-2' }, + { getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174003' }, + { getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174004' }, ]; const mockFixEntities = [ - { id: 'fix-1', title: 'Fix 1' }, - { id: 'fix-2', title: 'Fix 2' }, + { id: '123e4567-e89b-12d3-a456-426614174003', title: 'Fix 1' }, + { id: '123e4567-e89b-12d3-a456-426614174004', title: 'Fix 2' }, ]; const mockFixEntitySuggestionCollection = { @@ -122,10 +122,11 @@ describe('SuggestionCollection', () => { }; const mockFixEntityCollection = { - batchGetByIds: stub().resolves({ + batchGetByKeys: stub().resolves({ data: mockFixEntities, unprocessed: [], }), + idName: 'fixEntityId', }; mockEntityRegistry.getCollection @@ -137,18 +138,18 @@ describe('SuggestionCollection', () => { const result = await instance.getFixEntitiesBySuggestionId(suggestionId); - expect(result).to.deep.equal({ - data: mockFixEntities, - unprocessed: [], - }); + expect(result).to.deep.equal(mockFixEntities); expect(mockFixEntitySuggestionCollection.allBySuggestionId) .to.have.been.calledOnceWith(suggestionId); - expect(mockFixEntityCollection.batchGetByIds).to.have.been.calledOnceWith(['fix-1', 'fix-2']); + 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 = 'suggestion-123'; + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), removeByIndexKeys: stub().resolves(), @@ -160,22 +161,19 @@ describe('SuggestionCollection', () => { const result = await instance.getFixEntitiesBySuggestionId(suggestionId); - expect(result).to.deep.equal({ - data: [], - unprocessed: [], - }); + 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(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Failed to get fix entities: suggestionId is required'); + 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 = 'suggestion-123'; + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; const error = new Error('Database error'); const mockFixEntitySuggestionCollection = { @@ -195,12 +193,12 @@ describe('SuggestionCollection', () => { describe('setFixEntitiesBySuggestionId', () => { it('should set fix entities for a suggestion with delta updates', async () => { - const suggestionId = 'suggestion-123'; - const fixEntities = ['fix-1', 'fix-2']; + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const fixEntities = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174004']; const existingJunctionRecords = [ - { getId: () => 'junction-1', getFixEntityId: () => 'fix-1' }, - { getId: () => 'junction-2', getFixEntityId: () => 'fix-3' }, + { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174003' }, + { getId: () => 'junction-2', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174005' }, ]; const mockFixEntitySuggestionCollection = { @@ -229,25 +227,81 @@ describe('SuggestionCollection', () => { .to.have.been.calledOnceWith(suggestionId); expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([{ suggestionId, - fixEntityId: 'fix-3', + fixEntityId: '123e4567-e89b-12d3-a456-426614174005', }]); expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { suggestionId, fixEntityId: 'fix-2' }, + { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174004' }, ]); }); - it('should handle fix entities as model instances', async () => { - const suggestionId = 'suggestion-123'; - const fixEntityModels = [ - { getId: () => 'fix-1' }, - { getId: () => 'fix-2' }, - ]; + it('should throw error when suggestionId is not provided', async () => { + await expect(instance.setFixEntitiesBySuggestionId()) + .to.be.rejectedWith('Validation failed in SuggestionCollection: suggestionId must be a valid UUID'); + }); + + it('should throw error when fixEntities is not an array', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + + await expect(instance.setFixEntitiesBySuggestionId(suggestionId, 'not-an-array')) + .to.be.rejectedWith('Validation failed in SuggestionCollection: fixEntityIds must be an array'); + }); + + it('should handle errors and throw DataAccessError', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const fixEntities = ['123e4567-e89b-12d3-a456-426614174003']; + const error = new Error('Database error'); + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().rejects(error), + removeByIndexKeys: stub().resolves(), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + await expect(instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities)) + .to.be.rejectedWith(DataAccessError); + expect(mockLogger.error).to.have.been.calledWith('Failed to set fix entities for suggestion', error); + }); + + it('should log info about the operation results', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const fixEntities = ['123e4567-e89b-12d3-a456-426614174003']; const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ - createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + createdItems: [{ id: 'junction-1' }], + errorItems: [], + }), + }; + + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); + + await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + + expect(mockLogger.info).to.have.been.calledWith( + `Set fix entities for suggestion ${suggestionId}: removed 0, added 1, failed 0`, + ); + }); + + it('should handle remove operation failure gracefully', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const fixEntities = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174004']; + + const existingJunctionRecords = [ + { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174005' }, + ]; + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: stub().resolves(existingJunctionRecords), + removeByIndexKeys: stub().rejects(new Error('Remove failed')), + createMany: stub().resolves({ + createdItems: [{ id: 'junction-2' }, { id: 'junction-3' }], errorItems: [], }), }; @@ -256,60 +310,101 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntityModels); + const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); expect(result).to.deep.equal({ - createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], + createdItems: [{ id: 'junction-2' }, { id: 'junction-3' }], errorItems: [], - removedCount: 0, + removedCount: 0, // Failed operation results in 0 removed }); - expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { suggestionId, fixEntityId: 'fix-1' }, - { suggestionId, fixEntityId: 'fix-2' }, - ]); + expect(mockLogger.error).to.have.been.calledWith( + 'Remove operation failed:', + sinon.match.instanceOf(Error), + ); }); - it('should throw error when suggestionId is not provided', async () => { - await expect(instance.setFixEntitiesBySuggestionId()).to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Failed to set fix entities: suggestionId is required'); - }); + it('should handle create operation failure gracefully', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const fixEntities = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174004']; - it('should throw error when fixEntities is not an array', async () => { - const suggestionId = 'suggestion-123'; + const existingJunctionRecords = [ + { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174005' }, + ]; + + const mockFixEntitySuggestionCollection = { + allBySuggestionId: 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 instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + + expect(result).to.deep.equal({ + createdItems: [], // Failed operation results in empty array + errorItems: [], // Failed operation results in empty array + removedCount: 1, + }); - await expect(instance.setFixEntitiesBySuggestionId(suggestionId, 'not-an-array')).to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Fix entities must be an array'); + expect(mockLogger.error).to.have.been.calledWith( + 'Create operation failed:', + sinon.match.instanceOf(Error), + ); }); - it('should handle errors and throw DataAccessError', async () => { - const suggestionId = 'suggestion-123'; - const fixEntities = ['fix-1']; - const error = new Error('Database error'); + it('should handle empty fix entity array (remove all)', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const fixEntities = []; + + const existingJunctionRecords = [ + { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174003' }, + { getId: () => 'junction-2', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174004' }, + ]; const mockFixEntitySuggestionCollection = { - allBySuggestionId: stub().rejects(error), - removeByIndexKeys: stub().resolves(), + allBySuggestionId: stub().resolves(existingJunctionRecords), + removeByIndexKeys: stub().resolves([ + { id: 'junction-1' }, + { id: 'junction-2' }, + ]), + createMany: stub().resolves({ + createdItems: [], + errorItems: [], + }), }; mockEntityRegistry.getCollection .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - await expect(instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities)) - .to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Failed to set fix entities for suggestion', error); + const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + + expect(result).to.deep.equal({ + createdItems: [], + errorItems: [], + removedCount: 2, + }); + + expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([ + { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174003' }, + { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174004' }, + ]); + expect(mockFixEntitySuggestionCollection.createMany).to.not.have.been.called; }); - it('should log info about the operation results', async () => { - const suggestionId = 'suggestion-123'; - const fixEntities = ['fix-1']; + it('should handle no existing relationships (create all)', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const fixEntities = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174004']; const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), removeByIndexKeys: stub().resolves(), createMany: stub().resolves({ - createdItems: [{ id: 'junction-1' }], + createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], errorItems: [], }), }; @@ -318,19 +413,24 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); - expect(mockLogger.info).to.have.been.calledWith( - `Set fix entities for suggestion ${suggestionId}: removed 0, added 1, failed 0`, - ); + 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([ + { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174003' }, + { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174004' }, + ]); }); - it('should handle mixed input types (strings and models)', async () => { - const suggestionId = 'suggestion-123'; - const mixedInput = [ - 'fix-1', // string - { getId: () => 'fix-2' }, // model - ]; + it('should handle duplicate fix entity IDs in input', async () => { + const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const fixEntities = ['123e4567-e89b-12d3-a456-426614003', '123e4567-e89b-12d3-a456-426614003', '123e4567-e89b-12d3-a456-426614004']; const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), @@ -345,7 +445,7 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - const result = await instance.setFixEntitiesBySuggestionId(suggestionId, mixedInput); + const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], @@ -353,9 +453,10 @@ describe('SuggestionCollection', () => { removedCount: 0, }); + // Should only create unique fix entities expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { suggestionId, fixEntityId: 'fix-1' }, - { suggestionId, fixEntityId: 'fix-2' }, + { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614003' }, + { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614004' }, ]); }); }); From 7e0d60786b25873492aada0a9f890365c5cd805c Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Sat, 4 Oct 2025 01:03:43 +0530 Subject: [PATCH 08/12] fix: add custom logic for cascading function --- .../src/models/base/base.model.js | 9 +- .../fix-entity-suggestion.model.js | 11 ++ .../test/fixtures/fix-entity.fixture.js | 48 +++++ .../fix-entity-suggestion.test.js | 175 ++++++++++++++++++ .../fix-entity-suggestion.model.test.js | 113 +++++++++++ 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.model.test.js 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..b3daa1699 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,8 @@ class BaseModel { await Promise.all(removePromises); - await this.entity.remove({ [this.idName]: this.getId() }).go(); + await this.entity.remove(this.generateCompositeKeys()).go(); + console.log('REMOVED this.generateCompositeKeys()', this.generateCompositeKeys()); this.#invalidateCache(); 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 index e880052a0..beb4a0f1c 100644 --- 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 @@ -22,6 +22,17 @@ import BaseModel from '../base/base.model.js'; */ 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/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/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 index 88ab62510..08ab28046 100644 --- 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 @@ -512,4 +512,179 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { 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]; + + // Create relationships between fix entity and suggestions + await FixEntity.setSuggestionsByFixEntityId( + fixEntity.getId(), + [suggestion1.getId(), suggestion2.getId()], + ); + + // 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 + await Suggestion.setFixEntitiesBySuggestionId( + suggestion.getId(), + [fixEntity1.getId(), fixEntity2.getId()], + ); + + // 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]; + + // Create multiple relationships + await FixEntity.setSuggestionsByFixEntityId( + fixEntity1.getId(), + [suggestion1.getId(), suggestion2.getId()], + ); + await FixEntity.setSuggestionsByFixEntityId( + fixEntity2.getId(), + [suggestion1.getId()], // 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; + }); }); 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..e7475f2b6 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/fix-entity-suggestion/fix-entity-suggestion.model.test.js @@ -0,0 +1,113 @@ +/* + * 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', + 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; + }); + }); +}); From 67af0f2c9f3a939a062c7c534d9357c598f54714 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Fri, 10 Oct 2025 01:43:01 +0530 Subject: [PATCH 09/12] fix: adds index and update query patterns --- .../spacecat-shared-data-access/README.md | 45 ++ .../src/models/base/base.model.js | 1 - .../fix-entity-suggestion.collection.js | 20 +- .../fix-entity-suggestion.schema.js | 22 +- .../models/fix-entity-suggestion/index.d.ts | 5 + .../fix-entity/fix-entity.collection.js | 162 +++-- .../src/models/fix-entity/index.d.ts | 2 +- .../src/models/suggestion/index.d.ts | 2 +- .../suggestion/suggestion.collection.js | 46 +- .../fix-entity-suggestion.test.js | 567 ++++++++++++++---- .../test/it/fix-entity/fix-entity.test.js | 163 ++++- .../test/it/suggestion/suggestion.test.js | 13 +- .../fix-entity-suggestion.collection.test.js | 169 +++++- .../fix-entity-suggestion.model.test.js | 73 +++ .../fix-entity/fix-entity.collection.test.js | 354 +++++++---- .../suggestion/suggestion.collection.test.js | 187 ++++-- 16 files changed, 1478 insertions(+), 353 deletions(-) diff --git a/packages/spacecat-shared-data-access/README.md b/packages/spacecat-shared-data-access/README.md index 2a6bf8e45..b46a936d4 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,19 @@ 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 +- `setFixEntitiesForSuggestion` - Sets FixEntities for a Suggestion by managing junction table relationships + +### 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.model.js b/packages/spacecat-shared-data-access/src/models/base/base.model.js index b3daa1699..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 @@ -276,7 +276,6 @@ class BaseModel { await Promise.all(removePromises); await this.entity.remove(this.generateCompositeKeys()).go(); - console.log('REMOVED this.generateCompositeKeys()', this.generateCompositeKeys()); this.#invalidateCache(); 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 index 2b874ac2d..3d550126e 100644 --- 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 @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import { guardId } from '../../util/guards.js'; import BaseCollection from '../base/base.collection.js'; /** @@ -17,11 +18,28 @@ import BaseCollection from '../base/base.collection.js'; * 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.schema.js b/packages/spacecat-shared-data-access/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js index 608994a65..d054589cd 100644 --- 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 @@ -23,7 +23,27 @@ 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') - .addReference('belongs_to', 'FixEntity'); + .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 index 01d82b7e4..e1edaa7d0 100644 --- 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 @@ -19,9 +19,14 @@ export interface FixEntitySuggestion extends BaseModel { 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/fix-entity.collection.js b/packages/spacecat-shared-data-access/src/models/fix-entity/fix-entity.collection.js index 8dd833e4b..d44c12afa 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 @@ -11,47 +11,23 @@ */ import BaseCollection from '../base/base.collection.js'; import DataAccessError from '../../errors/data-access.error.js'; -import { guardId, guardArray } from '../../util/guards.js'; +import ValidationError from '../../errors/validation.error.js'; +import { guardId, guardArray, guardString } from '../../util/guards.js'; import { resolveUpdates } from '../../util/util.js'; /** * 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 { - /** - * Gets all Suggestions associated with an array of FixEntitySuggestion junction records. - * This is a helper method that takes junction records and retrieves the actual - * suggestion entities. - * - * @async - * @param {Array} fixEntitySuggestions - An array of FixEntitySuggestion junction records. - * @returns {Promise} - A promise that resolves to an array of Suggestion models - * @throws {DataAccessError} - Throws an error if the fixEntitySuggestions are not provided - * or if the query fails. - */ - async getSuggestionsByFixEntitySuggestions(fixEntitySuggestions) { - guardArray('fixEntitySuggestions', fixEntitySuggestions, 'FixEntityCollection', 'any'); - if (fixEntitySuggestions.length === 0) { - return []; - } - - try { - 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 suggestions', error); - throw new DataAccessError('Failed to get suggestions for fix entity suggestions', this, error); - } - } - /** * Gets all suggestions associated with a specific FixEntity. * @@ -70,7 +46,15 @@ class FixEntityCollection extends BaseCollection { const fixEntitySuggestions = await fixEntitySuggestionCollection .allByFixEntityId(fixEntityId); - return this.getSuggestionsByFixEntitySuggestions(fixEntitySuggestions); + 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); @@ -83,19 +67,33 @@ class FixEntityCollection extends BaseCollection { * new ones. * * @async - * @param {string} fixEntityId - The ID of the FixEntity. - * @param {Array} suggestionIds - An array of suggestion IDs (strings). + * @param {Opportunity} opportunity - The Opportunity entity. + * @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 fixEntityId is not provided or if the + * @throws {DataAccessError} - Throws an error if the entities are not provided or if the * operation fails. */ - async setSuggestionsByFixEntityId(fixEntityId, suggestionIds) { - guardId('fixEntityId', fixEntityId, 'FixEntityCollection'); - guardArray('suggestionIds', suggestionIds, 'FixEntityCollection'); + async setSuggestionsForFixEntity(opportunity, fixEntity, suggestions) { + guardArray('suggestions', suggestions, 'FixEntityCollection', 'any'); + + // Simple null checks + if (!opportunity) { + throw new ValidationError('opportunity is required'); + } + if (!fixEntity) { + throw new ValidationError('fixEntity is required'); + } + + // Extract IDs and other values from entities + const opportunityId = opportunity.getId(); + const fixEntityId = fixEntity.getId(); + const fixEntityCreatedAt = fixEntity.getCreatedAt(); + const suggestionIds = suggestions.map((suggestion) => suggestion.getId()); try { const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); @@ -124,6 +122,8 @@ class FixEntityCollection extends BaseCollection { if (toCreate.length > 0) { createPromise = fixEntitySuggestionCollection.createMany(toCreate.map((suggestionId) => ( { + opportunityId, + fixEntityCreatedAt, fixEntityId, suggestionId, }))); @@ -156,6 +156,92 @@ class FixEntityCollection extends BaseCollection { 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/index.d.ts b/packages/spacecat-shared-data-access/src/models/fix-entity/index.d.ts index 09d3f39dd..382c131dc 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 @@ -37,5 +37,5 @@ export interface FixEntityCollection extends BaseCollection { findByOpportunityId(opportunityId: string): Promise; findByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; getSuggestionsByFixEntityId(fixEntityId: string): Promise<{data: Array, unprocessed: Array}>; - setSuggestionsByFixEntityId(fixEntityId: string, suggestions: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; + setSuggestionsForFixEntity(opportunity: Opportunity, fixEntity: FixEntity, suggestions: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; } 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 19f670328..6c95a161c 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 @@ -34,5 +34,5 @@ export interface SuggestionCollection extends BaseCollection { findByOpportunityId(opportunityId: string): Promise; findByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; getFixEntitiesBySuggestionId(suggestionId: string): Promise<{data: Array, unprocessed: Array}>; - setFixEntitiesBySuggestionId(suggestionId: string, fixEntities: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; + setFixEntitiesForSuggestion(opportunity: Opportunity, suggestion: Suggestion, fixEntities: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; } 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 afcc18b2b..3e8c52035 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 @@ -13,12 +13,18 @@ import BaseCollection from '../base/base.collection.js'; import DataAccessError from '../../errors/data-access.error.js'; import Suggestion from './suggestion.model.js'; -import { guardId, guardArray } from '../../util/guards.js'; +import { guardId } from '../../util/guards.js'; import { resolveUpdates } from '../../util/util.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 + * - Set FixEntities for a Suggestion by managing junction table relationships * * @class SuggestionCollection * @extends BaseCollection @@ -95,19 +101,33 @@ class SuggestionCollection extends BaseCollection { * new ones. * * @async - * @param {string} suggestionId - The ID of the Suggestion. - * @param {Array} fixEntityIds - An array of fix entity IDs (strings). + * @param {Object} opportunity - The opportunity object. + * @param {Object} suggestion - The suggestion object. + * @param {Array} fixEntities - An array of fix entity objects. * @returns {Promise<{createdItems: Array, errorItems: Array, removedCount: number}>} - A promise * that resolves to an object containing: * - createdItems: Array of created FixEntitySuggestion junction records * - errorItems: Array of items that failed validation * - removedCount: Number of existing relationships that were removed - * @throws {DataAccessError} - Throws an error if the suggestionId is not provided or if the + * @throws {DataAccessError} - Throws an error if the parameters are not provided or if the * operation fails. */ - async setFixEntitiesBySuggestionId(suggestionId, fixEntityIds) { - guardId('suggestionId', suggestionId, 'SuggestionCollection'); - guardArray('fixEntityIds', fixEntityIds, 'SuggestionCollection'); + async setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities) { + if (!opportunity) { + throw new Error('Opportunity parameter is required'); + } + + if (!suggestion) { + throw new Error('Suggestion parameter is required'); + } + + if (!fixEntities) { + throw new Error('FixEntities parameter is required'); + } + + const suggestionId = suggestion.getId(); + const opportunityId = opportunity.getId(); + const fixEntityIds = fixEntities.map((entity) => entity.getId()); try { const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); @@ -127,11 +147,15 @@ class SuggestionCollection extends BaseCollection { suggestionId, fixEntityId, })); - const createKeys = toCreate.map((fixEntityId) => ( - { + const createKeys = toCreate.map((fixEntityId) => { + const fixEntity = fixEntities.find((entity) => entity.getId() === fixEntityId); + return { suggestionId, fixEntityId, - })); + opportunityId, + fixEntityCreatedAt: fixEntity.getCreatedAt(), + }; + }); if (toDelete.length > 0) { removePromise = fixEntitySuggestionCollection.removeByIndexKeys(deleteKeys); 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 index 08ab28046..8a255ca83 100644 --- 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 @@ -49,15 +49,19 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { it('sets suggestions for a fix entity using suggestion IDs', async () => { const fixEntity = sampleData.fixEntities[0]; - const suggestionIds = [ - sampleData.suggestions[0].getId(), - sampleData.suggestions[1].getId(), - sampleData.suggestions[2].getId(), + const suggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], + sampleData.suggestions[2], ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; - const result = await FixEntity.setSuggestionsByFixEntityId( - fixEntity.getId(), - suggestionIds, + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, + suggestions, ); expect(result).to.be.an('object'); @@ -68,19 +72,23 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify the relationships were created result.createdItems.forEach((item, index) => { expect(item.getFixEntityId()).to.equal(fixEntity.getId()); - expect(item.getSuggestionId()).to.equal(suggestionIds[index]); + 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].getId(), - sampleData.suggestions[4].getId(), + sampleData.suggestions[3], + sampleData.suggestions[4], ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; - const result = await FixEntity.setSuggestionsByFixEntityId( - fixEntity.getId(), + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, suggestions, ); @@ -92,33 +100,38 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify the relationships were created result.createdItems.forEach((item, index) => { expect(item.getFixEntityId()).to.equal(fixEntity.getId()); - expect(item.getSuggestionId()).to.equal(suggestions[index]); + 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 initialSuggestionIds = [ - sampleData.suggestions[0].getId(), - sampleData.suggestions[1].getId(), + const initialSuggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; // First, set initial suggestions - await FixEntity.setSuggestionsByFixEntityId( - fixEntity.getId(), - initialSuggestionIds, + await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, + initialSuggestions, ); // Then update with different suggestions - const newSuggestionIds = [ - sampleData.suggestions[1].getId(), // Keep this one - sampleData.suggestions[2].getId(), // Add this one - sampleData.suggestions[3].getId(), // Add this one + const newSuggestions = [ + sampleData.suggestions[1], // Keep this one + sampleData.suggestions[2], // Add this one + sampleData.suggestions[3], // Add this one ]; - const result = await FixEntity.setSuggestionsByFixEntityId( - fixEntity.getId(), - newSuggestionIds, + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, + newSuggestions, ); expect(result).to.be.an('object'); @@ -131,21 +144,27 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { expect(finalSuggestions).to.be.an('array').with.length(3); const finalSuggestionIds = finalSuggestions.map((s) => s.getId()).sort(); - expect(finalSuggestionIds).to.deep.equal(newSuggestionIds.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.setSuggestionsByFixEntityId( - fixEntity.getId(), - [sampleData.suggestions[0].getId(), sampleData.suggestions[1].getId()], + await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, + [sampleData.suggestions[0], sampleData.suggestions[1]], ); // Then remove all by setting empty array - const result = await FixEntity.setSuggestionsByFixEntityId( - fixEntity.getId(), + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, [], ); @@ -155,26 +174,31 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { expect(result.removedCount).to.equal(2); // Verify no suggestions remain - const suggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(suggestions).to.be.an('array').with.length(0); + const finalSuggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); + expect(finalSuggestions).to.be.an('array').with.length(0); }); - it('throws error when fixEntityId is not provided', async () => { + it('throws error when opportunity is not provided', async () => { + const fixEntity = sampleData.fixEntities[0]; await expect( - FixEntity.setSuggestionsByFixEntityId(null, []), - ).to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntityId must be a valid UUID'); + FixEntity.setSuggestionsForFixEntity(null, fixEntity, []), + ).to.be.rejectedWith('opportunity is required'); }); it('sets fix entities for a suggestion using fix entity IDs', async () => { const suggestion = sampleData.suggestions[0]; - const fixEntityIds = [ - sampleData.fixEntities[0].getId(), - sampleData.fixEntities[1].getId(), + const fixEntities = [ + sampleData.fixEntities[0], + sampleData.fixEntities[1], ]; + const opportunity = { + getId: () => 'opp-123', + }; - const result = await Suggestion.setFixEntitiesBySuggestionId( - suggestion.getId(), - fixEntityIds, + const result = await Suggestion.setFixEntitiesForSuggestion( + opportunity, + suggestion, + fixEntities, ); expect(result).to.be.an('object'); @@ -185,19 +209,23 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify the relationships were created result.createdItems.forEach((item, index) => { expect(item.getSuggestionId()).to.equal(suggestion.getId()); - expect(item.getFixEntityId()).to.equal(fixEntityIds[index]); + expect(item.getFixEntityId()).to.equal(fixEntities[index].getId()); }); }); it('sets fix entities for a suggestion using fix entity objects', async () => { const suggestion = sampleData.suggestions[1]; const fixEntities = [ - sampleData.fixEntities[0].getId(), - sampleData.fixEntities[2].getId(), + sampleData.fixEntities[0], + sampleData.fixEntities[2], ]; + const opportunity = { + getId: () => 'opp-123', + }; - const result = await Suggestion.setFixEntitiesBySuggestionId( - suggestion.getId(), + const result = await Suggestion.setFixEntitiesForSuggestion( + opportunity, + suggestion, fixEntities, ); @@ -209,32 +237,37 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify the relationships were created result.createdItems.forEach((item, index) => { expect(item.getSuggestionId()).to.equal(suggestion.getId()); - expect(item.getFixEntityId()).to.equal(fixEntities[index]); + expect(item.getFixEntityId()).to.equal(fixEntities[index].getId()); }); }); it('updates fix entities for a suggestion (removes old, adds new)', async () => { const suggestion = sampleData.suggestions[2]; - const initialFixEntityIds = [ - sampleData.fixEntities[0].getId(), - sampleData.fixEntities[1].getId(), + const initialFixEntities = [ + sampleData.fixEntities[0], + sampleData.fixEntities[1], ]; + const opportunity = { + getId: () => 'opp-123', + }; // First, set initial fix entities - await Suggestion.setFixEntitiesBySuggestionId( - suggestion.getId(), - initialFixEntityIds, + await Suggestion.setFixEntitiesForSuggestion( + opportunity, + suggestion, + initialFixEntities, ); // Then update with different fix entities - const newFixEntityIds = [ - sampleData.fixEntities[1].getId(), // Keep this one - sampleData.fixEntities[2].getId(), // Add this one + const newFixEntities = [ + sampleData.fixEntities[1], // Keep this one + sampleData.fixEntities[2], // Add this one ]; - const result = await Suggestion.setFixEntitiesBySuggestionId( - suggestion.getId(), - newFixEntityIds, + const result = await Suggestion.setFixEntitiesForSuggestion( + opportunity, + suggestion, + newFixEntities, ); expect(result).to.be.an('object'); @@ -247,24 +280,30 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { expect(finalFixEntities).to.be.an('array').with.length(2); const finalFixEntityIds = finalFixEntities.map((f) => f.getId()).sort(); - expect(finalFixEntityIds).to.deep.equal(newFixEntityIds.sort()); + const newFixEntityIds = newFixEntities.map((f) => f.getId()).sort(); + expect(finalFixEntityIds).to.deep.equal(newFixEntityIds); }); - it('throws error when suggestionId is not provided', async () => { + it('throws error when opportunity is not provided', async () => { + const suggestion = sampleData.suggestions[0]; + const fixEntities = []; await expect( - Suggestion.setFixEntitiesBySuggestionId(null, []), - ).to.be.rejectedWith('Validation failed in SuggestionCollection: suggestionId must be a valid UUID'); + Suggestion.setFixEntitiesForSuggestion(null, suggestion, fixEntities), + ).to.be.rejectedWith('Opportunity parameter is required'); }); it('gets all suggestions for a fix entity', async () => { const fixEntity = sampleData.fixEntities[0]; - const suggestionIds = [ - sampleData.suggestions[0].getId(), - sampleData.suggestions[1].getId(), + const suggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; // First set up the relationships - await FixEntity.setSuggestionsByFixEntityId(fixEntity.getId(), suggestionIds); + await FixEntity.setSuggestionsForFixEntity(opportunity, fixEntity, suggestions); // Then retrieve them const result = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); @@ -273,7 +312,8 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Verify the suggestions are correct const retrievedIds = result.map((s) => s.getId()).sort(); - expect(retrievedIds).to.deep.equal(suggestionIds.sort()); + const suggestionIds = suggestions.map((s) => s.getId()).sort(); + expect(retrievedIds).to.deep.equal(suggestionIds); // Verify they are proper suggestion objects result.forEach((suggestion) => { @@ -307,7 +347,9 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { ]; // First set up the relationships - await Suggestion.setFixEntitiesBySuggestionId(suggestion.getId(), fixEntityIds); + const opportunity = { getId: () => 'opp-123' }; + const fixEntities = fixEntityIds.map((id) => ({ getId: () => id, getCreatedAt: () => '2024-01-01T00:00:00Z' })); + await Suggestion.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities); // Then retrieve them const result = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); @@ -347,10 +389,14 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { { 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(), }, ]; @@ -368,11 +414,14 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { 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: sampleData.fixEntities[0].getId(), + fixEntityId: fixEntity.getId(), + opportunityId: fixEntity.getOpportunityId(), + fixEntityCreatedAt: fixEntity.getCreatedAt(), }); const junctionRecords = await FixEntitySuggestion.allBySuggestionId(suggestionId); @@ -387,12 +436,15 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { }); it('gets junction records by fix entity ID', async () => { - const fixEntityId = sampleData.fixEntities[0].getId(); + 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); @@ -408,16 +460,20 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { it('handles mixed valid and invalid suggestion IDs gracefully', async () => { const fixEntity = sampleData.fixEntities[0]; - const mixedIds = [ - sampleData.suggestions[0].getId(), // Valid - 'invalid-suggestion-id', // Invalid - sampleData.suggestions[1].getId(), // Valid + 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.setSuggestionsByFixEntityId( - fixEntity.getId(), - mixedIds, + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, + mixedSuggestions, ); // The behavior depends on validation - some items might be created, others might error @@ -428,15 +484,19 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { it('handles duplicate suggestion IDs in the input array', async () => { const fixEntity = sampleData.fixEntities[1]; - const duplicateIds = [ - sampleData.suggestions[0].getId(), - sampleData.suggestions[1].getId(), - sampleData.suggestions[0].getId(), // Duplicate + const duplicateSuggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], + sampleData.suggestions[0], // Duplicate ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; - const result = await FixEntity.setSuggestionsByFixEntityId( - fixEntity.getId(), - duplicateIds, + const result = await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, + duplicateSuggestions, ); // Should only create unique relationships @@ -447,42 +507,51 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { it('handles setting the same suggestions multiple times (idempotent)', async () => { const fixEntity = sampleData.fixEntities[2]; - const suggestionIds = [ - sampleData.suggestions[0].getId(), - sampleData.suggestions[1].getId(), + const suggestions = [ + sampleData.suggestions[0], + sampleData.suggestions[1], ]; + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; // Set suggestions first time - const result1 = await FixEntity.setSuggestionsByFixEntityId( - fixEntity.getId(), - suggestionIds, + const result1 = await FixEntity.setSuggestionsForFixEntity( + opportunity, + 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.setSuggestionsByFixEntityId( - fixEntity.getId(), - suggestionIds, + const result2 = await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, + suggestions, ); expect(result2.createdItems).to.be.an('array').with.length(0); expect(result2.removedCount).to.equal(0); // Verify final state - const suggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(suggestions).to.be.an('array').with.length(2); + 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.setSuggestionsByFixEntityId( - fixEntity.getId(), - [suggestion.getId()], + await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, + [suggestion], ); // Verify from Suggestion side @@ -493,9 +562,11 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { expect(fixEntitiesFromSuggestion[0].getId()).to.equal(fixEntity.getId()); // Set additional relationship from Suggestion side - await Suggestion.setFixEntitiesBySuggestionId( - suggestion.getId(), - [fixEntity.getId(), sampleData.fixEntities[1].getId()], + const opportunity2 = { getId: () => 'opp-123' }; + await Suggestion.setFixEntitiesForSuggestion( + opportunity2, + suggestion, + [fixEntity, sampleData.fixEntities[1]], ); // Verify from FixEntity side @@ -517,11 +588,15 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', 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.setSuggestionsByFixEntityId( - fixEntity.getId(), - [suggestion1.getId(), suggestion2.getId()], + await FixEntity.setSuggestionsForFixEntity( + opportunity, + fixEntity, + [suggestion1, suggestion2], ); // Verify relationships existy @@ -564,9 +639,11 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { const fixEntity2 = sampleData.fixEntities[2]; // Create relationships between suggestion and fix entities - await Suggestion.setFixEntitiesBySuggestionId( - suggestion.getId(), - [fixEntity1.getId(), fixEntity2.getId()], + const opportunity = { getId: () => 'opp-123' }; + await Suggestion.setFixEntitiesForSuggestion( + opportunity, + suggestion, + [fixEntity1, fixEntity2], ); // Verify relationships exist @@ -608,15 +685,23 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { 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.setSuggestionsByFixEntityId( - fixEntity1.getId(), - [suggestion1.getId(), suggestion2.getId()], + await FixEntity.setSuggestionsForFixEntity( + opportunity1, + fixEntity1, + [suggestion1, suggestion2], ); - await FixEntity.setSuggestionsByFixEntityId( - fixEntity2.getId(), - [suggestion1.getId()], // suggestion1 is related to both fix entities + await FixEntity.setSuggestionsForFixEntity( + opportunity2, + fixEntity2, + [suggestion1], // suggestion1 is related to both fix entities ); // Verify initial state @@ -687,4 +772,254 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { 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 999b4f828..9c163a8a9 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 @@ -39,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 () => { @@ -136,12 +139,16 @@ describe('FixEntity IT', async () => { const fixEntity = sampleData.fixEntities[0]; // First, set up some suggestions for this fix entity - const suggestionIds = [ - sampleData.suggestions[0].getId(), - sampleData.suggestions[1].getId(), + const suggestionsToSet = [ + sampleData.suggestions[0], + sampleData.suggestions[1], ]; - await FixEntity.setSuggestionsByFixEntityId(fixEntity.getId(), suggestionIds); + const opportunity = { + getId: () => fixEntity.getOpportunityId(), + }; + + await FixEntity.setSuggestionsForFixEntity(opportunity, fixEntity, suggestionsToSet); // Test the model method const suggestions = await fixEntity.getSuggestions(); @@ -149,7 +156,151 @@ describe('FixEntity IT', async () => { expect(suggestions).to.be.an('array').with.length(2); suggestions.forEach((suggestion) => { checkSuggestion(suggestion); - expect(suggestionIds).to.include(suggestion.getId()); + 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, fixEntity1, [suggestion1, suggestion2]); + + // Associate suggestion3 with fixEntity2 + await FixEntity.setSuggestionsForFixEntity(opportunity, 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/suggestion/suggestion.test.js b/packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js index 7887d20c0..b98a8ff1c 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 @@ -29,7 +29,8 @@ describe('Suggestion IT', async () => { let sampleData; let Suggestion; - beforeEach(async () => { + beforeEach(async function () { + this.timeout(10000); sampleData = await seedDatabase(); const dataAccess = getDataAccess(); @@ -263,13 +264,15 @@ describe('Suggestion IT', async () => { ]; // First, set up some fix entities for this suggestion - await Suggestion.setFixEntitiesBySuggestionId(suggestion.getId(), fixEntityIds); + const opportunity = { getId: () => 'opp-123' }; + const fixEntities = fixEntityIds.map((id) => ({ getId: () => id, getCreatedAt: () => '2024-01-01T00:00:00Z' })); + await Suggestion.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities); // Test the single suggestion method - const fixEntities = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); + const retrievedFixEntities = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); - expect(fixEntities).to.be.an('array').with.length(2); - fixEntities.forEach((fixEntity) => { + 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'); 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 index 260925a05..a9d662de9 100644 --- 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 @@ -24,6 +24,8 @@ describe('FixEntitySuggestionCollection', () => { const mockRecord = { suggestionId: 'suggestion-123', fixEntityId: 'fix-456', + fixEntityCreatedAt: '2024-01-01T00:00:00.000Z', + fixEntityCreatedDate: '2024-01-01', }; beforeEach(() => { @@ -37,10 +39,14 @@ describe('FixEntitySuggestionCollection', () => { describe('allBySuggestionId', () => { it('should get all junction records for a suggestion ID', async () => { - const suggestionId = 'suggestion-123'; + const suggestionId = '123e4567-e89b-12d3-a456-426614174000'; const expectedRecords = [ - { suggestionId, fixEntityId: 'fix-1' }, - { suggestionId, fixEntityId: 'fix-2' }, + { + 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); @@ -53,17 +59,17 @@ describe('FixEntitySuggestionCollection', () => { it('should throw error when suggestionId is not provided', async () => { await expect(collection.allBySuggestionId(null)) - .to.be.rejectedWith('suggestionId is required'); + .to.be.rejectedWith('suggestionId must be a valid UUID'); await expect(collection.allBySuggestionId('')) - .to.be.rejectedWith('suggestionId is required'); + .to.be.rejectedWith('suggestionId must be a valid UUID'); await expect(collection.allBySuggestionId(undefined)) - .to.be.rejectedWith('suggestionId is required'); + .to.be.rejectedWith('suggestionId must be a valid UUID'); }); it('should handle empty results', async () => { - const suggestionId = 'suggestion-nonexistent'; + const suggestionId = '123e4567-e89b-12d3-a456-426614174001'; collection.allByIndexKeys.resolves([]); const result = await collection.allBySuggestionId(suggestionId); @@ -72,12 +78,109 @@ describe('FixEntitySuggestionCollection', () => { }); }); + 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 = 'fix-123'; + const fixEntityId = '123e4567-e89b-12d3-a456-426614174002'; const expectedRecords = [ - { suggestionId: 'suggestion-1', fixEntityId }, - { suggestionId: 'suggestion-2', fixEntityId }, + { + 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); @@ -100,7 +203,7 @@ describe('FixEntitySuggestionCollection', () => { }); it('should handle empty results', async () => { - const fixEntityId = 'fix-nonexistent'; + const fixEntityId = '123e4567-e89b-12d3-a456-426614174005'; collection.allByIndexKeys.resolves([]); const result = await collection.allByFixEntityId(fixEntityId); @@ -165,8 +268,12 @@ describe('FixEntitySuggestionCollection', () => { it('should support createMany for bulk junction record creation', async () => { const junctionRecords = [ - { suggestionId: 'suggestion-1', fixEntityId: 'fix-1' }, - { suggestionId: 'suggestion-1', fixEntityId: 'fix-2' }, + { + 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 = { @@ -186,8 +293,12 @@ describe('FixEntitySuggestionCollection', () => { const ids = ['junction-1', 'junction-2']; const expectedResult = { data: [ - { id: 'junction-1', suggestionId: 'suggestion-1', fixEntityId: 'fix-1' }, - { id: 'junction-2', suggestionId: 'suggestion-1', fixEntityId: 'fix-2' }, + { + 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: [], }; @@ -215,25 +326,31 @@ describe('FixEntitySuggestionCollection', () => { const error = new Error('Database connection failed'); collection.allByIndexKeys.rejects(error); - await expect(collection.allBySuggestionId('suggestion-123')) + await expect(collection.allBySuggestionId('123e4567-e89b-12d3-a456-426614174006')) .to.be.rejectedWith('Database connection failed'); }); - it('should propagate errors from allByIndexKeys in allByFixEntityId', async () => { + it('should propagate errors from allByIndexKeys in allByOpportunityIdAndFixEntityCreatedDate', async () => { const error = new Error('Index not found'); collection.allByIndexKeys.rejects(error); - await expect(collection.allByFixEntityId('fix-123')) - .to.be.rejectedWith('Index not found'); + 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 = 'suggestion-with-many-fixes'; + const suggestionId = '123e4567-e89b-12d3-a456-426614174008'; const largeResultSet = Array.from({ length: 1000 }, (_, i) => ({ suggestionId, - fixEntityId: `fix-${i}`, + 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); @@ -245,12 +362,16 @@ describe('FixEntitySuggestionCollection', () => { }); it('should handle pagination through allByIndexKeys', async () => { - const fixEntityId = 'fix-with-many-suggestions'; + const fixEntityId = '123e4567-e89b-12d3-a456-426614174009'; // Mock paginated results collection.allByIndexKeys.resolves([ - { suggestionId: 'suggestion-1', fixEntityId }, - { suggestionId: 'suggestion-2', fixEntityId }, + { + 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); 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 index e7475f2b6..83b577dff 100644 --- 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 @@ -31,6 +31,8 @@ describe('FixEntitySuggestionModel', () => { 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', }; @@ -110,4 +112,75 @@ describe('FixEntitySuggestionModel', () => { 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 b5b522f90..1754291dc 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 @@ -15,6 +15,7 @@ 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'; describe('FixEntityCollection', () => { @@ -34,6 +35,21 @@ describe('FixEntityCollection', () => { 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(() => { ({ mockEntityRegistry, @@ -53,7 +69,7 @@ describe('FixEntityCollection', () => { { getSuggestionId: () => 'suggestion-1' }, { getSuggestionId: () => 'suggestion-2' }, ]; - const mockSuggestions = [ + const mockSuggestionData = [ { id: 'suggestion-1', title: 'Suggestion 1' }, { id: 'suggestion-2', title: 'Suggestion 2' }, ]; @@ -64,7 +80,7 @@ describe('FixEntityCollection', () => { const mockSuggestionCollection = { batchGetByKeys: stub().resolves({ - data: mockSuggestions, + data: mockSuggestionData, unprocessed: [], }), idName: 'suggestionId', @@ -79,7 +95,7 @@ describe('FixEntityCollection', () => { const result = await fixEntityCollection.getSuggestionsByFixEntityId(fixEntityId); - expect(result).to.deep.equal(mockSuggestions); + expect(result).to.deep.equal(mockSuggestionData); expect(mockFixEntitySuggestionCollection.allByFixEntityId) .to.have.been.calledOnceWith(fixEntityId); @@ -131,78 +147,38 @@ describe('FixEntityCollection', () => { .to.be.rejectedWith(DataAccessError); expect(mockLogger.error).to.have.been.calledWith(`Failed to get suggestions for fix entity: ${fixEntityId}`, error); }); - }); - describe('getSuggestionsByFixEntitySuggestions', () => { - it('should get suggestions from fix entity suggestions', async () => { - const fixEntitySuggestions = [ + it('should handle errors in batchGetByKeys and throw DataAccessError', async () => { + const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + const mockJunctionRecords = [ { getSuggestionId: () => 'suggestion-1' }, - { getSuggestionId: () => 'suggestion-2' }, - ]; - const mockSuggestions = [ - { id: 'suggestion-1', title: 'Suggestion 1' }, - { id: 'suggestion-2', title: 'Suggestion 2' }, ]; + const error = new Error('Batch get failed'); - const mockSuggestionCollection = { - batchGetByKeys: stub().resolves({ - data: mockSuggestions, - unprocessed: [], - }), - idName: 'suggestionId', + const mockFixEntitySuggestionCollection = { + allByFixEntityId: stub().resolves(mockJunctionRecords), }; - mockEntityRegistry.getCollection - .withArgs('SuggestionCollection') - .returns(mockSuggestionCollection); - - const result = await fixEntityCollection - .getSuggestionsByFixEntitySuggestions(fixEntitySuggestions); - - expect(result).to.deep.equal(mockSuggestions); - expect(mockSuggestionCollection.batchGetByKeys) - .to.have.been.calledOnceWith([ - { suggestionId: 'suggestion-1' }, - { suggestionId: 'suggestion-2' }, - ]); - }); - - it('should return empty array when no fix entity suggestions provided', async () => { - const result = await fixEntityCollection.getSuggestionsByFixEntitySuggestions([]); - expect(result).to.deep.equal([]); - }); - - it('should throw error when fixEntitySuggestions is not an array', async () => { - await expect(fixEntityCollection.getSuggestionsByFixEntitySuggestions('not-an-array')) - .to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntitySuggestions must be an array'); - }); - - it('should handle error in batchGetByKeys', async () => { - const fixEntitySuggestions = [ - { getSuggestionId: () => 'suggestion-1' }, - ]; - const mockSuggestionCollection = { - batchGetByKeys: stub().rejects(new Error('Batch get failed')), + batchGetByKeys: stub().rejects(error), idName: 'suggestionId', }; + mockEntityRegistry.getCollection + .withArgs('FixEntitySuggestionCollection') + .returns(mockFixEntitySuggestionCollection); mockEntityRegistry.getCollection .withArgs('SuggestionCollection') .returns(mockSuggestionCollection); - await expect(fixEntityCollection.getSuggestionsByFixEntitySuggestions(fixEntitySuggestions)) - .to.be.rejectedWith(DataAccessError, 'Failed to get suggestions for fix entity suggestions'); - - expect(mockLogger.error).to.have.been.calledWith('Failed to get suggestions for fix entity suggestions', sinon.match.instanceOf(Error)); + 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('setSuggestionsByFixEntityId', () => { + describe('setSuggestionsForFixEntity', () => { it('should set suggestions for a fix entity with delta updates', async () => { - const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = ['suggestion-1', 'suggestion-2']; - const existingJunctionRecords = [ { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-1' }, { getId: () => 'junction-2', getSuggestionId: () => 'suggestion-3' }, @@ -225,7 +201,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsByFixEntityId(fixEntityId, suggestions); + .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-3' }], @@ -234,33 +210,41 @@ describe('FixEntityCollection', () => { }); expect(mockFixEntitySuggestionCollection.allByFixEntityId) - .to.have.been.calledOnceWith(fixEntityId); + .to.have.been.calledOnceWith('123e4567-e89b-12d3-a456-426614174000'); expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([ { suggestionId: 'suggestion-3', - fixEntityId, + fixEntityId: '123e4567-e89b-12d3-a456-426614174000', }, ]); expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { fixEntityId, suggestionId: 'suggestion-2' }, + { + 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 fixEntityId is not provided', async () => { - await expect(fixEntityCollection.setSuggestionsByFixEntityId()) - .to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntityId must be a valid UUID'); + it('should throw error when opportunity is not provided', async () => { + await expect( + fixEntityCollection.setSuggestionsForFixEntity(null, mockFixEntity, mockSuggestions), + ).to.be.rejectedWith(ValidationError, 'opportunity is required'); }); - it('should throw error when suggestions is not an array', async () => { - const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; + it('should throw error when fixEntity is not provided', async () => { + await expect( + fixEntityCollection.setSuggestionsForFixEntity(mockOpportunity, null, mockSuggestions), + ).to.be.rejectedWith(ValidationError, 'fixEntity is required'); + }); - await expect(fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, 'not-an-array')) - .to.be.rejectedWith('Validation failed in FixEntityCollection: suggestionIds must be an array'); + it('should throw error when suggestions is not an array', async () => { + await expect(fixEntityCollection.setSuggestionsForFixEntity(mockOpportunity, 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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = ['suggestion-1']; const error = new Error('Database error'); const mockFixEntitySuggestionCollection = { @@ -272,14 +256,15 @@ describe('FixEntityCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - await expect(fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, suggestions)) - .to.be.rejectedWith(DataAccessError); + await expect( + fixEntityCollection + .setSuggestionsForFixEntity(mockOpportunity, 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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = ['suggestion-1']; + const singleSuggestion = [{ getId: () => 'suggestion-1' }]; const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves([]), @@ -294,17 +279,18 @@ describe('FixEntityCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - await fixEntityCollection.setSuggestionsByFixEntityId(fixEntityId, suggestions); + await fixEntityCollection.setSuggestionsForFixEntity( + mockOpportunity, + mockFixEntity, + singleSuggestion, + ); expect(mockLogger.info).to.have.been.calledWith( - `Set suggestions for fix entity ${fixEntityId}: removed 0, added 1, failed 0`, + '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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = ['suggestion-1', 'suggestion-2']; - const existingJunctionRecords = [ { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-3' }, ]; @@ -323,7 +309,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsByFixEntityId(fixEntityId, suggestions); + .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-2' }, { id: 'junction-3' }], @@ -338,9 +324,6 @@ describe('FixEntityCollection', () => { }); it('should handle create operation failure gracefully', async () => { - const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = ['suggestion-1', 'suggestion-2']; - const existingJunctionRecords = [ { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-3' }, ]; @@ -356,7 +339,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsByFixEntityId(fixEntityId, suggestions); + .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [], // Failed operation results in empty array @@ -371,9 +354,6 @@ describe('FixEntityCollection', () => { }); it('should handle both operations failing gracefully', async () => { - const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = ['suggestion-1', 'suggestion-2']; - const existingJunctionRecords = [ { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-3' }, ]; @@ -389,7 +369,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsByFixEntityId(fixEntityId, suggestions); + .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [], @@ -409,8 +389,7 @@ describe('FixEntityCollection', () => { }); it('should handle empty suggestion array (remove all)', async () => { - const fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = []; + const emptySuggestions = []; const existingJunctionRecords = [ { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-1' }, @@ -434,7 +413,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsByFixEntityId(fixEntityId, suggestions); + .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, emptySuggestions); expect(result).to.deep.equal({ createdItems: [], @@ -443,16 +422,13 @@ describe('FixEntityCollection', () => { }); expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([ - { suggestionId: 'suggestion-1', fixEntityId }, - { suggestionId: 'suggestion-2', fixEntityId }, + { 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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = ['suggestion-1', 'suggestion-2']; - const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves([]), removeByIndexKeys: stub().resolves(), @@ -467,7 +443,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsByFixEntityId(fixEntityId, suggestions); + .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], @@ -477,14 +453,28 @@ describe('FixEntityCollection', () => { expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.not.have.been.called; expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { fixEntityId, suggestionId: 'suggestion-1' }, - { fixEntityId, suggestionId: 'suggestion-2' }, + { + 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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = ['suggestion-1', 'suggestion-1', 'suggestion-2', 'suggestion-2']; + const duplicateSuggestions = [ + { getId: () => 'suggestion-1' }, + { getId: () => 'suggestion-1' }, + { getId: () => 'suggestion-2' }, + { getId: () => 'suggestion-2' }, + ]; const mockFixEntitySuggestionCollection = { allByFixEntityId: stub().resolves([]), @@ -500,7 +490,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsByFixEntityId(fixEntityId, suggestions); + .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, duplicateSuggestions); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], @@ -510,14 +500,23 @@ describe('FixEntityCollection', () => { // Should only create unique suggestions expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { fixEntityId, suggestionId: 'suggestion-1' }, - { fixEntityId, suggestionId: 'suggestion-2' }, + { + 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 fixEntityId = '123e4567-e89b-12d3-a456-426614174000'; - const suggestions = ['suggestion-1']; + const singleSuggestion = [{ getId: () => 'suggestion-1' }]; const existingJunctionRecords = [ { getId: () => 'junction-1', getSuggestionId: () => 'suggestion-1' }, @@ -537,7 +536,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsByFixEntityId(fixEntityId, suggestions); + .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, singleSuggestion); expect(result).to.deep.equal({ createdItems: [], @@ -550,4 +549,145 @@ describe('FixEntityCollection', () => { 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); + + 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/suggestion/suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/suggestion/suggestion.collection.test.js index 393dc5954..baa7de1b8 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 @@ -191,10 +191,16 @@ describe('SuggestionCollection', () => { }); }); - describe('setFixEntitiesBySuggestionId', () => { + describe('setFixEntitiesForSuggestion', () => { it('should set fix entities for a suggestion with delta updates', async () => { - const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; - const fixEntities = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174004']; + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const fixEntities = [ + { getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + { getId: () => '123e4567-e89b-12d3-a456-426614174004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + ]; + const opportunity = { + getId: () => 'opp-123', + }; const existingJunctionRecords = [ { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174003' }, @@ -215,7 +221,11 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + const result = await instance.setFixEntitiesForSuggestion( + opportunity, + suggestion, + fixEntities, + ); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-3' }], @@ -224,31 +234,67 @@ describe('SuggestionCollection', () => { }); expect(mockFixEntitySuggestionCollection.allBySuggestionId) - .to.have.been.calledOnceWith(suggestionId); + .to.have.been.calledOnceWith(suggestion.getId()); expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([{ - suggestionId, + suggestionId: suggestion.getId(), fixEntityId: '123e4567-e89b-12d3-a456-426614174005', }]); expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174004' }, + { + suggestionId: suggestion.getId(), + fixEntityId: '123e4567-e89b-12d3-a456-426614174004', + opportunityId: 'opp-123', + fixEntityCreatedAt: '2024-01-01T00:00:00Z', + }, ]); }); - it('should throw error when suggestionId is not provided', async () => { - await expect(instance.setFixEntitiesBySuggestionId()) - .to.be.rejectedWith('Validation failed in SuggestionCollection: suggestionId must be a valid UUID'); + it('should throw error when opportunity is not provided', async () => { + await expect(instance.setFixEntitiesForSuggestion()) + .to.be.rejectedWith('Opportunity parameter is required'); }); - it('should throw error when fixEntities is not an array', async () => { - const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + it('should throw error when fixEntities is not provided', async () => { + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const opportunity = { getId: () => 'opp-123' }; + + await expect(instance.setFixEntitiesForSuggestion(opportunity, suggestion, null)) + .to.be.rejectedWith('FixEntities parameter is required'); + }); + + it('should throw error when suggestion parameter is not provided', async () => { + const opportunity = { getId: () => 'opp-123' }; + const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003' }]; + await expect(instance.setFixEntitiesForSuggestion(opportunity, null, fixEntities)) + .to.be.rejectedWith('Suggestion parameter is required'); + }); + + it('should throw error when opportunity is null', async () => { + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003' }]; + await expect(instance.setFixEntitiesForSuggestion(null, suggestion, fixEntities)) + .to.be.rejectedWith('Opportunity parameter is required'); + }); + + it('should throw error when fixEntities is null', async () => { + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const opportunity = { getId: () => 'opp-123' }; + await expect(instance.setFixEntitiesForSuggestion(opportunity, suggestion, null)) + .to.be.rejectedWith('FixEntities parameter is required'); + }); - await expect(instance.setFixEntitiesBySuggestionId(suggestionId, 'not-an-array')) - .to.be.rejectedWith('Validation failed in SuggestionCollection: fixEntityIds must be an array'); + it('should throw error when suggestion is null', async () => { + const suggestion = null; + const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003' }]; + const opportunity = { getId: () => 'opp-123' }; + await expect(instance.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities)) + .to.be.rejectedWith('Suggestion parameter is required'); }); it('should handle errors and throw DataAccessError', async () => { - const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; - const fixEntities = ['123e4567-e89b-12d3-a456-426614174003']; + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }]; + const opportunity = { getId: () => 'opp-123' }; const error = new Error('Database error'); const mockFixEntitySuggestionCollection = { @@ -260,14 +306,15 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - await expect(instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities)) + await expect(instance.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities)) .to.be.rejectedWith(DataAccessError); expect(mockLogger.error).to.have.been.calledWith('Failed to set fix entities for suggestion', error); }); it('should log info about the operation results', async () => { - const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; - const fixEntities = ['123e4567-e89b-12d3-a456-426614174003']; + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }]; + const opportunity = { getId: () => 'opp-123' }; const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), @@ -282,16 +329,20 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + await instance.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities); expect(mockLogger.info).to.have.been.calledWith( - `Set fix entities for suggestion ${suggestionId}: removed 0, added 1, failed 0`, + `Set fix entities for suggestion ${suggestion.getId()}: removed 0, added 1, failed 0`, ); }); it('should handle remove operation failure gracefully', async () => { - const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; - const fixEntities = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174004']; + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const fixEntities = [ + { getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + { getId: () => '123e4567-e89b-12d3-a456-426614174004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + ]; + const opportunity = { getId: () => 'opp-123' }; const existingJunctionRecords = [ { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174005' }, @@ -310,7 +361,11 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + const result = await instance.setFixEntitiesForSuggestion( + opportunity, + suggestion, + fixEntities, + ); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-2' }, { id: 'junction-3' }], @@ -325,8 +380,12 @@ describe('SuggestionCollection', () => { }); it('should handle create operation failure gracefully', async () => { - const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; - const fixEntities = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174004']; + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const fixEntities = [ + { getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + { getId: () => '123e4567-e89b-12d3-a456-426614174004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + ]; + const opportunity = { getId: () => 'opp-123' }; const existingJunctionRecords = [ { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174005' }, @@ -342,7 +401,11 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + const result = await instance.setFixEntitiesForSuggestion( + opportunity, + suggestion, + fixEntities, + ); expect(result).to.deep.equal({ createdItems: [], // Failed operation results in empty array @@ -357,8 +420,9 @@ describe('SuggestionCollection', () => { }); it('should handle empty fix entity array (remove all)', async () => { - const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; const fixEntities = []; + const opportunity = { getId: () => 'opp-123' }; const existingJunctionRecords = [ { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174003' }, @@ -381,7 +445,11 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + const result = await instance.setFixEntitiesForSuggestion( + opportunity, + suggestion, + fixEntities, + ); expect(result).to.deep.equal({ createdItems: [], @@ -390,15 +458,19 @@ describe('SuggestionCollection', () => { }); expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([ - { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174003' }, - { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174004' }, + { suggestionId: suggestion.getId(), fixEntityId: '123e4567-e89b-12d3-a456-426614174003' }, + { suggestionId: suggestion.getId(), fixEntityId: '123e4567-e89b-12d3-a456-426614174004' }, ]); expect(mockFixEntitySuggestionCollection.createMany).to.not.have.been.called; }); it('should handle no existing relationships (create all)', async () => { - const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; - const fixEntities = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174004']; + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const fixEntities = [ + { getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + { getId: () => '123e4567-e89b-12d3-a456-426614174004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + ]; + const opportunity = { getId: () => 'opp-123' }; const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), @@ -413,7 +485,11 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + const result = await instance.setFixEntitiesForSuggestion( + opportunity, + suggestion, + fixEntities, + ); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], @@ -423,14 +499,29 @@ describe('SuggestionCollection', () => { expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.not.have.been.called; expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174003' }, - { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614174004' }, + { + suggestionId: suggestion.getId(), + fixEntityId: '123e4567-e89b-12d3-a456-426614174003', + opportunityId: 'opp-123', + fixEntityCreatedAt: '2024-01-01T00:00:00Z', + }, + { + suggestionId: suggestion.getId(), + fixEntityId: '123e4567-e89b-12d3-a456-426614174004', + opportunityId: 'opp-123', + fixEntityCreatedAt: '2024-01-01T00:00:00Z', + }, ]); }); it('should handle duplicate fix entity IDs in input', async () => { - const suggestionId = '123e4567-e89b-12d3-a456-426614174002'; - const fixEntities = ['123e4567-e89b-12d3-a456-426614003', '123e4567-e89b-12d3-a456-426614003', '123e4567-e89b-12d3-a456-426614004']; + const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; + const fixEntities = [ + { getId: () => '123e4567-e89b-12d3-a456-426614003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + { getId: () => '123e4567-e89b-12d3-a456-426614003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + { getId: () => '123e4567-e89b-12d3-a456-426614004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, + ]; + const opportunity = { getId: () => 'opp-123' }; const mockFixEntitySuggestionCollection = { allBySuggestionId: stub().resolves([]), @@ -445,7 +536,11 @@ describe('SuggestionCollection', () => { .withArgs('FixEntitySuggestionCollection') .returns(mockFixEntitySuggestionCollection); - const result = await instance.setFixEntitiesBySuggestionId(suggestionId, fixEntities); + const result = await instance.setFixEntitiesForSuggestion( + opportunity, + suggestion, + fixEntities, + ); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], @@ -455,8 +550,18 @@ describe('SuggestionCollection', () => { // Should only create unique fix entities expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614003' }, - { suggestionId, fixEntityId: '123e4567-e89b-12d3-a456-426614004' }, + { + suggestionId: suggestion.getId(), + fixEntityId: '123e4567-e89b-12d3-a456-426614003', + opportunityId: 'opp-123', + fixEntityCreatedAt: '2024-01-01T00:00:00Z', + }, + { + suggestionId: suggestion.getId(), + fixEntityId: '123e4567-e89b-12d3-a456-426614004', + opportunityId: 'opp-123', + fixEntityCreatedAt: '2024-01-01T00:00:00Z', + }, ]); }); }); From 5cde916e8a86421c991a9dd1c17ba43b764794e6 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Fri, 10 Oct 2025 11:27:30 +0530 Subject: [PATCH 10/12] fix: simplify param and remove unnecessary functions --- .../spacecat-shared-data-access/README.md | 1 - .../fix-entity/fix-entity.collection.js | 9 +- .../src/models/fix-entity/index.d.ts | 2 +- .../src/models/suggestion/index.d.ts | 1 - .../suggestion/suggestion.collection.js | 101 ----- .../fix-entity-suggestion.test.js | 188 +++------ .../test/it/fix-entity/fix-entity.test.js | 7 +- .../test/it/suggestion/suggestion.test.js | 14 +- .../fix-entity/fix-entity.collection.test.js | 29 +- .../suggestion/suggestion.collection.test.js | 377 +----------------- 10 files changed, 81 insertions(+), 648 deletions(-) diff --git a/packages/spacecat-shared-data-access/README.md b/packages/spacecat-shared-data-access/README.md index b46a936d4..0691d28f0 100644 --- a/packages/spacecat-shared-data-access/README.md +++ b/packages/spacecat-shared-data-access/README.md @@ -183,7 +183,6 @@ The module provides the following DAOs: ### Suggestion Functions - `bulkUpdateStatus` - Updates the status of multiple suggestions in bulk - `getFixEntitiesBySuggestionId` - Gets all FixEntities associated with a specific Suggestion -- `setFixEntitiesForSuggestion` - Sets FixEntities for a Suggestion by managing junction table relationships ### FixEntitySuggestion Functions - `allBySuggestionId` - Gets all junction records associated with a specific Suggestion 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 d44c12afa..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 @@ -67,7 +67,7 @@ class FixEntityCollection extends BaseCollection { * new ones. * * @async - * @param {Opportunity} opportunity - The Opportunity entity. + * @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 @@ -78,19 +78,16 @@ class FixEntityCollection extends BaseCollection { * @throws {DataAccessError} - Throws an error if the entities are not provided or if the * operation fails. */ - async setSuggestionsForFixEntity(opportunity, fixEntity, suggestions) { + async setSuggestionsForFixEntity(opportunityId, fixEntity, suggestions) { + guardId('opportunityId', opportunityId, 'FixEntityCollection'); guardArray('suggestions', suggestions, 'FixEntityCollection', 'any'); // Simple null checks - if (!opportunity) { - throw new ValidationError('opportunity is required'); - } if (!fixEntity) { throw new ValidationError('fixEntity is required'); } // Extract IDs and other values from entities - const opportunityId = opportunity.getId(); const fixEntityId = fixEntity.getId(); const fixEntityCreatedAt = fixEntity.getCreatedAt(); const suggestionIds = suggestions.map((suggestion) => suggestion.getId()); 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 382c131dc..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 @@ -37,5 +37,5 @@ export interface FixEntityCollection extends BaseCollection { findByOpportunityId(opportunityId: string): Promise; findByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; getSuggestionsByFixEntityId(fixEntityId: string): Promise<{data: Array, unprocessed: Array}>; - setSuggestionsForFixEntity(opportunity: Opportunity, fixEntity: FixEntity, suggestions: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; + setSuggestionsForFixEntity(opportunityId: string, fixEntity: FixEntity, suggestions: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; } 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 6c95a161c..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 @@ -34,5 +34,4 @@ export interface SuggestionCollection extends BaseCollection { findByOpportunityId(opportunityId: string): Promise; findByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; getFixEntitiesBySuggestionId(suggestionId: string): Promise<{data: Array, unprocessed: Array}>; - setFixEntitiesForSuggestion(opportunity: Opportunity, suggestion: Suggestion, fixEntities: Array): Promise<{createdItems: Array, errorItems: Array, removedCount: number}>; } 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 3e8c52035..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 @@ -14,7 +14,6 @@ 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'; -import { resolveUpdates } from '../../util/util.js'; /** * SuggestionCollection - A collection class responsible for managing Suggestion entities. @@ -24,7 +23,6 @@ import { resolveUpdates } from '../../util/util.js'; * This collection provides methods to: * - Update the status of multiple suggestions in bulk * - Retrieve FixEntities associated with a specific Suggestion - * - Set FixEntities for a Suggestion by managing junction table relationships * * @class SuggestionCollection * @extends BaseCollection @@ -93,105 +91,6 @@ class SuggestionCollection extends BaseCollection { throw new DataAccessError('Failed to get fix entities for suggestion', this, error); } } - - /** - * Sets FixEntities for a specific Suggestion by replacing all existing fix entities with new - * ones. - * This method efficiently only removes relationships that are no longer needed and only adds - * new ones. - * - * @async - * @param {Object} opportunity - The opportunity object. - * @param {Object} suggestion - The suggestion object. - * @param {Array} fixEntities - An array of fix entity objects. - * @returns {Promise<{createdItems: Array, errorItems: Array, removedCount: number}>} - A promise - * that resolves to an object containing: - * - createdItems: Array of created FixEntitySuggestion junction records - * - errorItems: Array of items that failed validation - * - removedCount: Number of existing relationships that were removed - * @throws {DataAccessError} - Throws an error if the parameters are not provided or if the - * operation fails. - */ - async setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities) { - if (!opportunity) { - throw new Error('Opportunity parameter is required'); - } - - if (!suggestion) { - throw new Error('Suggestion parameter is required'); - } - - if (!fixEntities) { - throw new Error('FixEntities parameter is required'); - } - - const suggestionId = suggestion.getId(); - const opportunityId = opportunity.getId(); - const fixEntityIds = fixEntities.map((entity) => entity.getId()); - - try { - const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection'); - - const existingRelationships = await fixEntitySuggestionCollection - .allBySuggestionId(suggestionId); - - // Extract existing fix entity IDs from relationship objects - const existingFixEntityIds = existingRelationships.map((rel) => rel.getFixEntityId()); - - const { toDelete, toCreate } = resolveUpdates(existingFixEntityIds, fixEntityIds); - - let removePromise; - let createPromise; - const deleteKeys = toDelete.map((fixEntityId) => ( - { - suggestionId, - fixEntityId, - })); - const createKeys = toCreate.map((fixEntityId) => { - const fixEntity = fixEntities.find((entity) => entity.getId() === fixEntityId); - return { - suggestionId, - fixEntityId, - opportunityId, - fixEntityCreatedAt: fixEntity.getCreatedAt(), - }; - }); - - if (toDelete.length > 0) { - removePromise = fixEntitySuggestionCollection.removeByIndexKeys(deleteKeys); - } - - if (toCreate.length > 0) { - createPromise = fixEntitySuggestionCollection.createMany(createKeys); - } - - const [removeResult, createResult] = await Promise.allSettled([removePromise, createPromise]); - - let removedCount = 0; - let createdItems = []; - let errorItems = []; - if (removeResult.status === 'fulfilled') { - removedCount = deleteKeys.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 fix entities for suggestion ${suggestionId}: removed ${removedCount}, ` - + `added ${createdItems.length}, failed ${errorItems.length}`); - - return { createdItems, errorItems, removedCount }; - } catch (error) { - this.log.error('Failed to set fix entities for suggestion', error); - throw new DataAccessError('Failed to set fix entities for suggestion', this, error); - } - } } export default SuggestionCollection; 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 index 8a255ca83..b491ca873 100644 --- 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 @@ -59,7 +59,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { }; const result = await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, suggestions, ); @@ -87,7 +87,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { }; const result = await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, suggestions, ); @@ -116,7 +116,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // First, set initial suggestions await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, initialSuggestions, ); @@ -129,7 +129,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { ]; const result = await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, newSuggestions, ); @@ -156,14 +156,14 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // First add some suggestions await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, [sampleData.suggestions[0], sampleData.suggestions[1]], ); // Then remove all by setting empty array const result = await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, [], ); @@ -178,118 +178,11 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { expect(finalSuggestions).to.be.an('array').with.length(0); }); - it('throws error when opportunity is not provided', async () => { + it('throws error when opportunityId is not provided', async () => { const fixEntity = sampleData.fixEntities[0]; await expect( FixEntity.setSuggestionsForFixEntity(null, fixEntity, []), - ).to.be.rejectedWith('opportunity is required'); - }); - - it('sets fix entities for a suggestion using fix entity IDs', async () => { - const suggestion = sampleData.suggestions[0]; - const fixEntities = [ - sampleData.fixEntities[0], - sampleData.fixEntities[1], - ]; - const opportunity = { - getId: () => 'opp-123', - }; - - const result = await Suggestion.setFixEntitiesForSuggestion( - opportunity, - suggestion, - fixEntities, - ); - - 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.getSuggestionId()).to.equal(suggestion.getId()); - expect(item.getFixEntityId()).to.equal(fixEntities[index].getId()); - }); - }); - - it('sets fix entities for a suggestion using fix entity objects', async () => { - const suggestion = sampleData.suggestions[1]; - const fixEntities = [ - sampleData.fixEntities[0], - sampleData.fixEntities[2], - ]; - const opportunity = { - getId: () => 'opp-123', - }; - - const result = await Suggestion.setFixEntitiesForSuggestion( - opportunity, - suggestion, - fixEntities, - ); - - 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.getSuggestionId()).to.equal(suggestion.getId()); - expect(item.getFixEntityId()).to.equal(fixEntities[index].getId()); - }); - }); - - it('updates fix entities for a suggestion (removes old, adds new)', async () => { - const suggestion = sampleData.suggestions[2]; - const initialFixEntities = [ - sampleData.fixEntities[0], - sampleData.fixEntities[1], - ]; - const opportunity = { - getId: () => 'opp-123', - }; - - // First, set initial fix entities - await Suggestion.setFixEntitiesForSuggestion( - opportunity, - suggestion, - initialFixEntities, - ); - - // Then update with different fix entities - const newFixEntities = [ - sampleData.fixEntities[1], // Keep this one - sampleData.fixEntities[2], // Add this one - ]; - - const result = await Suggestion.setFixEntitiesForSuggestion( - opportunity, - suggestion, - newFixEntities, - ); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(1); // Added 1 new - expect(result.errorItems).to.be.an('array').with.length(0); - expect(result.removedCount).to.equal(1); // Removed 1 old - - // Verify final state - const finalFixEntities = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); - expect(finalFixEntities).to.be.an('array').with.length(2); - - const finalFixEntityIds = finalFixEntities.map((f) => f.getId()).sort(); - const newFixEntityIds = newFixEntities.map((f) => f.getId()).sort(); - expect(finalFixEntityIds).to.deep.equal(newFixEntityIds); - }); - - it('throws error when opportunity is not provided', async () => { - const suggestion = sampleData.suggestions[0]; - const fixEntities = []; - await expect( - Suggestion.setFixEntitiesForSuggestion(null, suggestion, fixEntities), - ).to.be.rejectedWith('Opportunity parameter is required'); + ).to.be.rejectedWith('Validation failed in FixEntityCollection: opportunityId must be a valid UUID'); }); it('gets all suggestions for a fix entity', async () => { @@ -303,7 +196,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { }; // First set up the relationships - await FixEntity.setSuggestionsForFixEntity(opportunity, fixEntity, suggestions); + await FixEntity.setSuggestionsForFixEntity(opportunity.getId(), fixEntity, suggestions); // Then retrieve them const result = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); @@ -346,10 +239,14 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { sampleData.fixEntities[1].getId(), ]; - // First set up the relationships - const opportunity = { getId: () => 'opp-123' }; - const fixEntities = fixEntityIds.map((id) => ({ getId: () => id, getCreatedAt: () => '2024-01-01T00:00:00Z' })); - await Suggestion.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities); + // 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()); @@ -471,7 +368,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // This should not throw an error, but should handle validation at the junction level const result = await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, mixedSuggestions, ); @@ -494,7 +391,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { }; const result = await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, duplicateSuggestions, ); @@ -517,7 +414,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Set suggestions first time const result1 = await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, suggestions, ); @@ -527,7 +424,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Set the same suggestions again const result2 = await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, suggestions, ); @@ -549,7 +446,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Set relationship from FixEntity side await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, [suggestion], ); @@ -561,12 +458,12 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { expect(fixEntitiesFromSuggestion).to.be.an('array').with.length(1); expect(fixEntitiesFromSuggestion[0].getId()).to.equal(fixEntity.getId()); - // Set additional relationship from Suggestion side - const opportunity2 = { getId: () => 'opp-123' }; - await Suggestion.setFixEntitiesForSuggestion( - opportunity2, - suggestion, - [fixEntity, sampleData.fixEntities[1]], + // 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 @@ -594,7 +491,7 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Create relationships between fix entity and suggestions await FixEntity.setSuggestionsForFixEntity( - opportunity, + opportunity.getId(), fixEntity, [suggestion1, suggestion2], ); @@ -638,13 +535,22 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { const fixEntity1 = sampleData.fixEntities[1]; const fixEntity2 = sampleData.fixEntities[2]; - // Create relationships between suggestion and fix entities - const opportunity = { getId: () => 'opp-123' }; - await Suggestion.setFixEntitiesForSuggestion( - opportunity, - suggestion, - [fixEntity1, fixEntity2], - ); + // 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({ @@ -694,12 +600,12 @@ describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { // Create multiple relationships await FixEntity.setSuggestionsForFixEntity( - opportunity1, + opportunity1.getId(), fixEntity1, [suggestion1, suggestion2], ); await FixEntity.setSuggestionsForFixEntity( - opportunity2, + opportunity2.getId(), fixEntity2, [suggestion1], // suggestion1 is related to both fix entities ); 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 9c163a8a9..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 @@ -148,7 +148,7 @@ describe('FixEntity IT', async () => { getId: () => fixEntity.getOpportunityId(), }; - await FixEntity.setSuggestionsForFixEntity(opportunity, fixEntity, suggestionsToSet); + await FixEntity.setSuggestionsForFixEntity(opportunity.getId(), fixEntity, suggestionsToSet); // Test the model method const suggestions = await fixEntity.getSuggestions(); @@ -227,10 +227,11 @@ describe('FixEntity IT', async () => { const opportunity = { getId: () => opportunityId }; // Associate suggestion1 and suggestion2 with fixEntity1 - await FixEntity.setSuggestionsForFixEntity(opportunity, fixEntity1, [suggestion1, suggestion2]); + await FixEntity + .setSuggestionsForFixEntity(opportunity.getId(), fixEntity1, [suggestion1, suggestion2]); // Associate suggestion3 with fixEntity2 - await FixEntity.setSuggestionsForFixEntity(opportunity, fixEntity2, [suggestion3]); + await FixEntity.setSuggestionsForFixEntity(opportunity.getId(), fixEntity2, [suggestion3]); // Test the getAllFixesWithSuggestionByCreatedAt method const result = await FixEntity.getAllFixesWithSuggestionByCreatedAt( 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 b98a8ff1c..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 @@ -28,6 +28,7 @@ use(chaiAsPromised); describe('Suggestion IT', async () => { let sampleData; let Suggestion; + let FixEntitySuggestion; beforeEach(async function () { this.timeout(10000); @@ -35,6 +36,7 @@ describe('Suggestion IT', async () => { const dataAccess = getDataAccess(); Suggestion = dataAccess.Suggestion; + FixEntitySuggestion = dataAccess.FixEntitySuggestion; }); it('finds one suggestion by id', async () => { @@ -263,10 +265,14 @@ describe('Suggestion IT', async () => { sampleData.fixEntities[2].getId(), ]; - // First, set up some fix entities for this suggestion - const opportunity = { getId: () => 'opp-123' }; - const fixEntities = fixEntityIds.map((id) => ({ getId: () => id, getCreatedAt: () => '2024-01-01T00:00:00Z' })); - await Suggestion.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities); + // 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()); 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 1754291dc..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 @@ -201,7 +201,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-3' }], @@ -227,20 +227,21 @@ describe('FixEntityCollection', () => { ]); }); - it('should throw error when opportunity is not provided', async () => { + it('should throw error when opportunityId is not provided', async () => { await expect( fixEntityCollection.setSuggestionsForFixEntity(null, mockFixEntity, mockSuggestions), - ).to.be.rejectedWith(ValidationError, 'opportunity is required'); + ).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, null, mockSuggestions), + 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, mockFixEntity, 'not-an-array')) + await expect(fixEntityCollection.setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, 'not-an-array')) .to.be.rejectedWith('Validation failed in FixEntityCollection: suggestions must be an array'); }); @@ -258,7 +259,7 @@ describe('FixEntityCollection', () => { await expect( fixEntityCollection - .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions), + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions), ).to.be.rejectedWith(DataAccessError); expect(mockLogger.error).to.have.been.calledWith('Failed to set suggestions for fix entity', error); }); @@ -280,7 +281,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); await fixEntityCollection.setSuggestionsForFixEntity( - mockOpportunity, + mockOpportunity.getId(), mockFixEntity, singleSuggestion, ); @@ -309,7 +310,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-2' }, { id: 'junction-3' }], @@ -339,7 +340,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [], // Failed operation results in empty array @@ -369,7 +370,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [], @@ -413,7 +414,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, emptySuggestions); + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, emptySuggestions); expect(result).to.deep.equal({ createdItems: [], @@ -443,7 +444,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, mockSuggestions); + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, mockSuggestions); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], @@ -490,7 +491,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, duplicateSuggestions); + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, duplicateSuggestions); expect(result).to.deep.equal({ createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], @@ -536,7 +537,7 @@ describe('FixEntityCollection', () => { .returns(mockFixEntitySuggestionCollection); const result = await fixEntityCollection - .setSuggestionsForFixEntity(mockOpportunity, mockFixEntity, singleSuggestion); + .setSuggestionsForFixEntity(mockOpportunity.getId(), mockFixEntity, singleSuggestion); expect(result).to.deep.equal({ createdItems: [], 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 baa7de1b8..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,7 +15,7 @@ import { expect, use as chaiUse } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; -import sinon, { stub, restore } from 'sinon'; +import { stub, restore } from 'sinon'; import Suggestion from '../../../../src/models/suggestion/suggestion.model.js'; import DataAccessError from '../../../../src/errors/data-access.error.js'; @@ -190,379 +190,4 @@ describe('SuggestionCollection', () => { expect(mockLogger.error).to.have.been.calledWith('Failed to get fix entities for suggestion', error); }); }); - - describe('setFixEntitiesForSuggestion', () => { - it('should set fix entities for a suggestion with delta updates', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const fixEntities = [ - { getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - { getId: () => '123e4567-e89b-12d3-a456-426614174004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - ]; - const opportunity = { - getId: () => 'opp-123', - }; - - const existingJunctionRecords = [ - { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174003' }, - { getId: () => 'junction-2', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174005' }, - ]; - - const mockFixEntitySuggestionCollection = { - allBySuggestionId: stub().resolves(existingJunctionRecords), - removeByIds: stub().resolves(), - removeByIndexKeys: stub().resolves(), - createMany: stub().resolves({ - createdItems: [{ id: 'junction-3' }], - errorItems: [], - }), - }; - - mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestionCollection') - .returns(mockFixEntitySuggestionCollection); - - const result = await instance.setFixEntitiesForSuggestion( - opportunity, - suggestion, - fixEntities, - ); - - expect(result).to.deep.equal({ - createdItems: [{ id: 'junction-3' }], - errorItems: [], - removedCount: 1, - }); - - expect(mockFixEntitySuggestionCollection.allBySuggestionId) - .to.have.been.calledOnceWith(suggestion.getId()); - expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([{ - suggestionId: suggestion.getId(), - fixEntityId: '123e4567-e89b-12d3-a456-426614174005', - }]); - expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { - suggestionId: suggestion.getId(), - fixEntityId: '123e4567-e89b-12d3-a456-426614174004', - opportunityId: 'opp-123', - fixEntityCreatedAt: '2024-01-01T00:00:00Z', - }, - ]); - }); - - it('should throw error when opportunity is not provided', async () => { - await expect(instance.setFixEntitiesForSuggestion()) - .to.be.rejectedWith('Opportunity parameter is required'); - }); - - it('should throw error when fixEntities is not provided', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const opportunity = { getId: () => 'opp-123' }; - - await expect(instance.setFixEntitiesForSuggestion(opportunity, suggestion, null)) - .to.be.rejectedWith('FixEntities parameter is required'); - }); - - it('should throw error when suggestion parameter is not provided', async () => { - const opportunity = { getId: () => 'opp-123' }; - const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003' }]; - await expect(instance.setFixEntitiesForSuggestion(opportunity, null, fixEntities)) - .to.be.rejectedWith('Suggestion parameter is required'); - }); - - it('should throw error when opportunity is null', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003' }]; - await expect(instance.setFixEntitiesForSuggestion(null, suggestion, fixEntities)) - .to.be.rejectedWith('Opportunity parameter is required'); - }); - - it('should throw error when fixEntities is null', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const opportunity = { getId: () => 'opp-123' }; - await expect(instance.setFixEntitiesForSuggestion(opportunity, suggestion, null)) - .to.be.rejectedWith('FixEntities parameter is required'); - }); - - it('should throw error when suggestion is null', async () => { - const suggestion = null; - const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003' }]; - const opportunity = { getId: () => 'opp-123' }; - await expect(instance.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities)) - .to.be.rejectedWith('Suggestion parameter is required'); - }); - - it('should handle errors and throw DataAccessError', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }]; - const opportunity = { getId: () => 'opp-123' }; - const error = new Error('Database error'); - - const mockFixEntitySuggestionCollection = { - allBySuggestionId: stub().rejects(error), - removeByIndexKeys: stub().resolves(), - }; - - mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestionCollection') - .returns(mockFixEntitySuggestionCollection); - - await expect(instance.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities)) - .to.be.rejectedWith(DataAccessError); - expect(mockLogger.error).to.have.been.calledWith('Failed to set fix entities for suggestion', error); - }); - - it('should log info about the operation results', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const fixEntities = [{ getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }]; - const opportunity = { getId: () => 'opp-123' }; - - const mockFixEntitySuggestionCollection = { - allBySuggestionId: stub().resolves([]), - removeByIndexKeys: stub().resolves(), - createMany: stub().resolves({ - createdItems: [{ id: 'junction-1' }], - errorItems: [], - }), - }; - - mockEntityRegistry.getCollection - .withArgs('FixEntitySuggestionCollection') - .returns(mockFixEntitySuggestionCollection); - - await instance.setFixEntitiesForSuggestion(opportunity, suggestion, fixEntities); - - expect(mockLogger.info).to.have.been.calledWith( - `Set fix entities for suggestion ${suggestion.getId()}: removed 0, added 1, failed 0`, - ); - }); - - it('should handle remove operation failure gracefully', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const fixEntities = [ - { getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - { getId: () => '123e4567-e89b-12d3-a456-426614174004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - ]; - const opportunity = { getId: () => 'opp-123' }; - - const existingJunctionRecords = [ - { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174005' }, - ]; - - const mockFixEntitySuggestionCollection = { - allBySuggestionId: 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 instance.setFixEntitiesForSuggestion( - opportunity, - suggestion, - fixEntities, - ); - - 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 suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const fixEntities = [ - { getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - { getId: () => '123e4567-e89b-12d3-a456-426614174004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - ]; - const opportunity = { getId: () => 'opp-123' }; - - const existingJunctionRecords = [ - { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174005' }, - ]; - - const mockFixEntitySuggestionCollection = { - allBySuggestionId: 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 instance.setFixEntitiesForSuggestion( - opportunity, - suggestion, - fixEntities, - ); - - 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 empty fix entity array (remove all)', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const fixEntities = []; - const opportunity = { getId: () => 'opp-123' }; - - const existingJunctionRecords = [ - { getId: () => 'junction-1', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174003' }, - { getId: () => 'junction-2', getFixEntityId: () => '123e4567-e89b-12d3-a456-426614174004' }, - ]; - - const mockFixEntitySuggestionCollection = { - allBySuggestionId: 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 instance.setFixEntitiesForSuggestion( - opportunity, - suggestion, - fixEntities, - ); - - expect(result).to.deep.equal({ - createdItems: [], - errorItems: [], - removedCount: 2, - }); - - expect(mockFixEntitySuggestionCollection.removeByIndexKeys).to.have.been.calledOnceWith([ - { suggestionId: suggestion.getId(), fixEntityId: '123e4567-e89b-12d3-a456-426614174003' }, - { suggestionId: suggestion.getId(), fixEntityId: '123e4567-e89b-12d3-a456-426614174004' }, - ]); - expect(mockFixEntitySuggestionCollection.createMany).to.not.have.been.called; - }); - - it('should handle no existing relationships (create all)', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const fixEntities = [ - { getId: () => '123e4567-e89b-12d3-a456-426614174003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - { getId: () => '123e4567-e89b-12d3-a456-426614174004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - ]; - const opportunity = { getId: () => 'opp-123' }; - - const mockFixEntitySuggestionCollection = { - allBySuggestionId: 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 instance.setFixEntitiesForSuggestion( - opportunity, - suggestion, - fixEntities, - ); - - 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([ - { - suggestionId: suggestion.getId(), - fixEntityId: '123e4567-e89b-12d3-a456-426614174003', - opportunityId: 'opp-123', - fixEntityCreatedAt: '2024-01-01T00:00:00Z', - }, - { - suggestionId: suggestion.getId(), - fixEntityId: '123e4567-e89b-12d3-a456-426614174004', - opportunityId: 'opp-123', - fixEntityCreatedAt: '2024-01-01T00:00:00Z', - }, - ]); - }); - - it('should handle duplicate fix entity IDs in input', async () => { - const suggestion = { getId: () => '123e4567-e89b-12d3-a456-426614174002' }; - const fixEntities = [ - { getId: () => '123e4567-e89b-12d3-a456-426614003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - { getId: () => '123e4567-e89b-12d3-a456-426614003', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - { getId: () => '123e4567-e89b-12d3-a456-426614004', getCreatedAt: () => '2024-01-01T00:00:00Z' }, - ]; - const opportunity = { getId: () => 'opp-123' }; - - const mockFixEntitySuggestionCollection = { - allBySuggestionId: 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 instance.setFixEntitiesForSuggestion( - opportunity, - suggestion, - fixEntities, - ); - - expect(result).to.deep.equal({ - createdItems: [{ id: 'junction-1' }, { id: 'junction-2' }], - errorItems: [], - removedCount: 0, - }); - - // Should only create unique fix entities - expect(mockFixEntitySuggestionCollection.createMany).to.have.been.calledOnceWith([ - { - suggestionId: suggestion.getId(), - fixEntityId: '123e4567-e89b-12d3-a456-426614003', - opportunityId: 'opp-123', - fixEntityCreatedAt: '2024-01-01T00:00:00Z', - }, - { - suggestionId: suggestion.getId(), - fixEntityId: '123e4567-e89b-12d3-a456-426614004', - opportunityId: 'opp-123', - fixEntityCreatedAt: '2024-01-01T00:00:00Z', - }, - ]); - }); - }); }); From 0f8b767339091fa32cdcda7337c3c09599eb4f27 Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Mon, 13 Oct 2025 01:56:05 +0530 Subject: [PATCH 11/12] fix: update addFixEntities function --- .../models/opportunity/opportunity.model.js | 143 +++++++++- .../test/it/opportunity/opportunity.test.js | 244 ++++++++++++++++++ .../opportunity/opportunity.model.test.js | 171 +++++++++++- 3 files changed, 546 insertions(+), 12 deletions(-) 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/test/it/opportunity/opportunity.test.js b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js index e5e8a5321..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(() => { @@ -367,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/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); }); }); From 168520b804fd73f9cb13fcd68b5b32149dcd035a Mon Sep 17 00:00:00 2001 From: Sandesh Sinha Date: Thu, 16 Oct 2025 16:50:54 +0530 Subject: [PATCH 12/12] fix: adds projection in batchGetBykeys --- .../src/models/base/base.collection.js | 12 ++- .../src/models/base/index.d.ts | 6 +- .../test/it/site/site.test.js | 96 +++++++++++++++++++ .../unit/models/base/base.collection.test.js | 48 ++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) 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 514eb3717..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 @@ -367,6 +367,7 @@ class BaseCollection { * * @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: @@ -375,13 +376,20 @@ class BaseCollection { * @throws {DataAccessError} - Throws an error if the IDs are not provided or if the batch * operation fails. */ - async batchGetByKeys(keys) { + 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(); + ).go(goOptions); // Process found entities const data = result.data 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 b0344b8fc..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,13 +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[]): Promise<{ data: T[]; unprocessed: object[] }>; + 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/test/it/site/site.test.js b/packages/spacecat-shared-data-access/test/it/site/site.test.js index e7fe52da4..ef843e533 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/unit/models/base/base.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/base/base.collection.test.js index 2af392571..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 @@ -1192,6 +1192,54 @@ describe('BaseCollection', () => { 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', () => {