From 12ecc3b7ded8e22830950e4e5381c9790976566a Mon Sep 17 00:00:00 2001 From: ekremney Date: Fri, 13 Feb 2026 16:54:13 +0100 Subject: [PATCH 01/20] feat(data-access): migrate v3 data layer to PostgREST --- package-lock.json | 179 +++- .../docker-compose.test.yml | 39 + .../spacecat-shared-data-access/package.json | 1 + .../spacecat-shared-data-access/src/index.js | 12 +- .../src/models/audit/audit.collection.js | 26 +- .../src/models/base/base.collection.js | 913 ++++++++++-------- .../src/models/base/base.model.js | 19 +- .../src/models/base/entity.registry.js | 8 +- .../src/models/base/schema.builder.js | 1 + .../models/key-event/key-event.collection.js | 21 +- .../latest-audit/latest-audit.collection.js | 81 +- .../src/service/index.d.ts | 8 +- .../src/service/index.js | 83 +- .../src/util/index.js | 2 +- .../src/util/patcher.js | 166 ++-- .../src/util/postgrest.utils.js | 127 +++ .../models/audit/audit.collection.test.js | 9 +- .../unit/models/base/base.collection.test.js | 595 +++++++++++- .../test/unit/models/base/base.model.test.js | 26 + .../unit/models/base/schema.builder.test.js | 2 + .../key-event/key-event.collection.test.js | 13 + .../latest-audit.collection.test.js | 126 ++- .../test/unit/service/index.test.js | 63 ++ .../test/unit/util/patcher.test.js | 35 + .../test/unit/util/postgrest.utils.test.js | 191 ++++ .../spacecat-shared-data-access/tsconfig.json | 24 + 26 files changed, 2124 insertions(+), 646 deletions(-) create mode 100644 packages/spacecat-shared-data-access/docker-compose.test.yml mode change 100755 => 100644 packages/spacecat-shared-data-access/src/models/base/base.collection.js mode change 100755 => 100644 packages/spacecat-shared-data-access/src/util/patcher.js create mode 100644 packages/spacecat-shared-data-access/src/util/postgrest.utils.js create mode 100644 packages/spacecat-shared-data-access/test/unit/service/index.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/util/postgrest.utils.test.js create mode 100644 packages/spacecat-shared-data-access/tsconfig.json diff --git a/package-lock.json b/package-lock.json index f5a49a1f3..0e8a88996 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2794,6 +2794,11 @@ "node": ">=18" } }, + "node_modules/@mysticat/data-service-types": { + "version": "1.7.0", + "resolved": "git+ssh://git@github.com/adobe/mysticat-data-service.git#f8ead5c18c576ea18a29e1c70310ba6bf180cb41", + "license": "Apache-2.0" + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -4933,6 +4938,27 @@ "integrity": "sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==", "license": "MIT" }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.21.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.4.tgz", + "integrity": "sha512-TxZCIjxk6/dP9abAi89VQbWWMBbybpGWyvmIzTd79OeravM13OjR/YEYeyUOPcM1C3QyvXkvPZhUfItvmhY1IQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -7749,19 +7775,6 @@ "readable-stream": "^2.0.2" } }, - "node_modules/dynamo-db-local": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/dynamo-db-local/-/dynamo-db-local-9.6.0.tgz", - "integrity": "sha512-/q0bPbYmQqpnRbM6qYiz4/+WgO86ZaYNQxvjfZHkXSwYL2oF5wgp3WBRYEKlEU+N3cHdYD12fMibIz0ECNXxIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=18.2.0" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -15764,6 +15777,49 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -15773,10 +15829,20 @@ "node": ">=4.0.0" } }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, "node_modules/pg-protocol": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", "license": "MIT" }, "node_modules/pg-types": { @@ -15795,6 +15861,26 @@ "node": ">=4" } }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pgpass/node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -18442,6 +18528,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", @@ -19257,6 +19349,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -19278,6 +19376,16 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -20028,26 +20136,24 @@ }, "packages/spacecat-shared-data-access": { "name": "@adobe/spacecat-shared-data-access", - "version": "2.104.0", + "version": "2.106.0", "license": "Apache-2.0", "dependencies": { "@adobe/spacecat-shared-utils": "1.81.1", - "@aws-sdk/client-dynamodb": "3.940.0", "@aws-sdk/client-s3": "^3.940.0", - "@aws-sdk/lib-dynamodb": "3.940.0", - "@types/joi": "17.2.3", - "aws-xray-sdk": "3.12.0", - "electrodb": "3.5.0", - "joi": "18.0.2", - "pluralize": "8.0.0" + "@mysticat/data-service-types": "github:adobe/mysticat-data-service#types-ts-v1.7.0", + "@supabase/postgrest-js": "^1.19.4", + "joi": "18.0.2" }, "devDependencies": { + "@types/node": "^22.0.0", "chai": "6.2.1", "chai-as-promised": "8.0.2", - "dynamo-db-local": "9.6.0", "nock": "14.0.10", + "pg": "^8.13.0", "sinon": "21.0.0", - "sinon-chai": "4.0.1" + "sinon-chai": "4.0.1", + "typescript": "^5.7.0" }, "engines": { "node": ">=22.0.0 <25.0.0", @@ -20078,6 +20184,23 @@ "npm": ">=10.9.0 <12.0.0" } }, + "packages/spacecat-shared-data-access/node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/spacecat-shared-data-access/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "packages/spacecat-shared-example": { "name": "@adobe/spacecat-shared-example", "version": "1.2.33", @@ -20772,7 +20895,7 @@ }, "packages/spacecat-shared-tokowaka-client": { "name": "@adobe/spacecat-shared-tokowaka-client", - "version": "1.7.6", + "version": "1.7.7", "license": "Apache-2.0", "dependencies": { "@adobe/spacecat-shared-utils": "1.81.1", @@ -20836,7 +20959,7 @@ }, "packages/spacecat-shared-utils": { "name": "@adobe/spacecat-shared-utils", - "version": "1.91.0", + "version": "1.95.0", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.2.3", diff --git a/packages/spacecat-shared-data-access/docker-compose.test.yml b/packages/spacecat-shared-data-access/docker-compose.test.yml new file mode 100644 index 000000000..e0bd34f15 --- /dev/null +++ b/packages/spacecat-shared-data-access/docker-compose.test.yml @@ -0,0 +1,39 @@ +# Docker Compose for integration testing — PostgreSQL + PostgREST +name: spacecat-data-access-test + +services: + db: + image: postgres:16-alpine + container_name: spacecat-test-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: spacecat_test + ports: + - "54320:5432" + tmpfs: + - /var/lib/postgresql/data + volumes: + - ./test/it/v3/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 3s + retries: 10 + + postgrest: + image: postgrest/postgrest:v14.4 + container_name: spacecat-test-postgrest + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://postgrest_authenticator:postgrest@db:5432/spacecat_test + PGRST_DB_SCHEMAS: public + PGRST_DB_ANON_ROLE: postgrest_anon + PGRST_DB_EXTRA_SEARCH_PATH: "" + PGRST_LOG_LEVEL: warn + PGRST_ADMIN_SERVER_PORT: 3001 + ports: + - "3456:3000" + - "3457:3001" diff --git a/packages/spacecat-shared-data-access/package.json b/packages/spacecat-shared-data-access/package.json index e1d061976..50ac673d5 100644 --- a/packages/spacecat-shared-data-access/package.json +++ b/packages/spacecat-shared-data-access/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@adobe/spacecat-shared-utils": "1.81.1", + "@supabase/postgrest-js": "^1.21.4", "@aws-sdk/client-dynamodb": "3.940.0", "@aws-sdk/client-s3": "^3.940.0", "@aws-sdk/lib-dynamodb": "3.940.0", diff --git a/packages/spacecat-shared-data-access/src/index.js b/packages/spacecat-shared-data-access/src/index.js index 35ee9139f..07f9947e8 100644 --- a/packages/spacecat-shared-data-access/src/index.js +++ b/packages/spacecat-shared-data-access/src/index.js @@ -14,7 +14,7 @@ import { createDataAccess } from './service/index.js'; export * from './service/index.js'; -const TABLE_NAME_DATA = 'spacecat-services-data'; +const POSTGREST_URL = 'http://localhost:3000'; /** * Wrapper for data access layer @@ -25,7 +25,7 @@ export default function dataAccessWrapper(fn) { /** * Wrapper for data access layer. This wrapper will create a data access layer if it is not * already created. It requires the context to have a log object. It will also use the - * DYNAMO_TABLE_NAME_DATA environment variable to create the data access layer. + * POSTGREST_URL environment variable to create the data access layer. * Optionally, it will use the ENV and AWS_REGION environment variables * * @param {object} request - The request object @@ -37,13 +37,17 @@ export default function dataAccessWrapper(fn) { const { log } = context; const { - DYNAMO_TABLE_NAME_DATA = TABLE_NAME_DATA, + POSTGREST_URL: postgrestUrl = POSTGREST_URL, + POSTGREST_SCHEMA: postgrestSchema, + POSTGREST_API_KEY: postgrestApiKey, S3_CONFIG_BUCKET: s3Bucket, AWS_REGION: region, } = context.env; context.dataAccess = createDataAccess({ - tableNameData: DYNAMO_TABLE_NAME_DATA, + postgrestUrl, + postgrestSchema, + postgrestApiKey, s3Bucket, region, }, log); diff --git a/packages/spacecat-shared-data-access/src/models/audit/audit.collection.js b/packages/spacecat-shared-data-access/src/models/audit/audit.collection.js index aea6398a6..a698c5a42 100755 --- a/packages/spacecat-shared-data-access/src/models/audit/audit.collection.js +++ b/packages/spacecat-shared-data-access/src/models/audit/audit.collection.js @@ -22,30 +22,16 @@ import BaseCollection from '../base/base.collection.js'; class AuditCollection extends BaseCollection { static COLLECTION_NAME = 'AuditCollection'; - // create a copy of the audit as a LatestAudit entity + // LatestAudit is derived from audits in v3; no copy table writes. + // eslint-disable-next-line class-methods-use-this,no-unused-vars async _onCreate(item) { - const collection = this.entityRegistry.getCollection('LatestAuditCollection'); - await collection.create(item.toJSON()); + // no-op } - // of the created audits, find the latest per site and auditType - // and create a LatestAudit copy for each + // LatestAudit is derived from audits in v3; no copy table writes. + // eslint-disable-next-line class-methods-use-this,no-unused-vars async _onCreateMany(items) { - const collection = this.entityRegistry.getCollection('LatestAuditCollection'); - const latestAudits = items.createdItems.reduce((acc, audit) => { - const siteId = audit.getSiteId(); - const auditType = audit.getAuditType(); - const auditedAt = audit.getAuditedAt(); - const key = `${siteId}-${auditType}`; - - if (!acc[key] || acc[key].getAuditedAt() < auditedAt) { - acc[key] = audit; - } - - return acc; - }, {}); - - await collection.createMany(Object.values(latestAudits).map((audit) => audit.toJSON())); + // no-op } } 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 old mode 100755 new mode 100644 index b111c4044..02d6462b1 --- a/packages/spacecat-shared-data-access/src/models/base/base.collection.js +++ b/packages/spacecat-shared-data-access/src/models/base/base.collection.js @@ -16,7 +16,6 @@ import { isNonEmptyObject, isObject, } from '@adobe/spacecat-shared-utils'; - import { ElectroValidationError } from 'electrodb'; import DataAccessError from '../../errors/data-access.error.js'; @@ -24,10 +23,18 @@ import ValidationError from '../../errors/validation.error.js'; import { createAccessors } from '../../util/accessor.utils.js'; import { guardId, guardArray } from '../../util/guards.js'; import { - entityNameToAllPKValue, - removeElectroProperties, -} from '../../util/util.js'; + applyWhere, + createFieldMaps, + decodeCursor, + DEFAULT_PAGE_SIZE, + encodeCursor, + entityToTableName, + fromDbRecord, + toDbField, + toDbRecord, +} from '../../util/postgrest.utils.js'; import { DATASTORE_TYPE } from '../../util/index.js'; +import { entityNameToAllPKValue, removeElectroProperties } from '../../util/util.js'; function isValidParent(parent, child) { if (!hasText(parent.entityName)) { @@ -35,44 +42,18 @@ function isValidParent(parent, child) { } const foreignKey = `${parent.entityName}Id`; - return child.record?.[foreignKey] === parent.record?.[foreignKey]; } -/** - * BaseCollection - A base class for managing collections of entities in the application. - * This class uses ElectroDB to interact with entities and provides common functionality - * for data operations. - * - * @class BaseCollection - * @abstract - */ class BaseCollection { - /** - * The collection name for this collection. Must be overridden by subclasses. - * This ensures the collection name is explicit and not dependent on class names - * which can be mangled by bundlers. - * @type {string} - */ static COLLECTION_NAME = undefined; - /** - * The datastore type for this collection. Defaults to DYNAMO. - * Override in subclasses to use a different datastore (e.g., S3). - * @type {string} - */ - static DATASTORE_TYPE = DATASTORE_TYPE.DYNAMO; - - /** - * Constructs an instance of BaseCollection. - * @constructor - * @param {Object} electroService - The ElectroDB service used for managing entities. - * @param {Object} entityRegistry - The registry holding entities, their schema and collection. - * @param {Object} schema - The schema for the entity. - * @param {Object} log - A log for capturing logging information. - */ - constructor(electroService, entityRegistry, schema, log) { - this.electroService = electroService; + static DATASTORE_TYPE = DATASTORE_TYPE.POSTGREST; + + constructor(postgrestService, entityRegistry, schema, log) { + this.postgrestService = postgrestService; + // legacy alias for existing tests and callers + this.electroService = postgrestService; this.entityRegistry = entityRegistry; this.schema = schema; this.log = log; @@ -80,7 +61,9 @@ class BaseCollection { this.clazz = this.schema.getModelClass(); this.entityName = this.schema.getEntityName(); this.idName = this.schema.getIdName(); - this.entity = electroService.entities[this.entityName]; + this.tableName = entityToTableName(this.schema.getModelName()); + this.fieldMaps = createFieldMaps(this.schema); + this.entity = postgrestService?.entities?.[this.entityName]; this.#initializeCollectionMethods(); } @@ -94,34 +77,11 @@ class BaseCollection { throw error; } - /** - * Initialize collection methods for each "by..." index defined in the entity schema. - * For each index that starts with "by", we: - * 1. Retrieve its composite pk and sk arrays from the schema. - * 2. Generate convenience methods for every prefix of the composite keys. - * For example, if the index keys are ['opportunityId', 'status', 'createdAt'], - * we create methods: - * - allByOpportunityId(...) / findByOpportunityId(...) - * - allByOpportunityIdAndStatus(...) / findByOpportunityIdAndStatus(...) - * - allByOpportunityIdAndStatusAndCreatedAt(...) / - * findByOpportunityIdAndStatusAndCreatedAt(...) - * - * Each generated method calls allByIndexKeys() or findByIndexKeys() with the appropriate keys. - * - * @private - */ #initializeCollectionMethods() { const accessorConfigs = this.schema.toAccessorConfigs(this, this.log); createAccessors(accessorConfigs, this.log); } - /** - * Creates an instance of a model from a record. - * @private - * @param {Object} record - The record containing data to create the model instance. - * @returns {BaseModel|null} - Returns an instance of the model class if the data is valid, - * otherwise null. - */ #createInstance(record) { if (!isNonEmptyObject(record)) { this.log.warn(`Failed to create instance of [${this.entityName}]: record is empty`); @@ -129,7 +89,7 @@ class BaseCollection { } // eslint-disable-next-line new-cap return new this.clazz( - this.electroService, + this.postgrestService, this.entityRegistry, this.schema, record, @@ -137,34 +97,16 @@ class BaseCollection { ); } - /** - * Creates instances of models from a set of records. - * @private - * @param {Object} records - The records containing data to create the model instances. - * @returns {Array} - An array of instances of the model class. - */ #createInstances(records) { - return records.map((record) => this.#createInstance(record)); + return records + .map((record) => this.#createInstance(record)) + .filter((instance) => instance !== null); } - /** - * Clears the accessor cache for the entity. This method is called when the entity is - * updated or removed to ensure that the cache is invalidated. - * @private - */ #invalidateCache() { this._accessorCache = {}; } - /** - * Internal on-create handler. This method is called after the create method has successfully - * created an entity. It will call the on-create handler defined in the subclass and handles - * any errors that occur. - * @param {BaseModel} item - The created entity. - * @return {Promise} - * @async - * @private - */ async #onCreate(item) { try { await this._onCreate(item); @@ -173,16 +115,6 @@ class BaseCollection { } } - /** - * Internal on-create-many handler. This method is called after the createMany method has - * successfully created entities. It will call the on-create-many handler defined in the - * subclass and handles any errors that occur. - * @param {Array} createdItems - The created entities. - * @param {{ item: Object, error: ValidationError }[]} errorItems - Items that failed validation. - * @return {Promise} - * @async - * @private - */ async #onCreateMany({ createdItems, errorItems }) { try { await this._onCreateMany({ createdItems, errorItems }); @@ -191,47 +123,213 @@ class BaseCollection { } } - /** - * Handler for the create method. This method is - * called after the create method has successfully created an entity. - * @param {BaseModel} item - The created entity. - * @return {Promise} - * @async - * @protected - */ // eslint-disable-next-line class-methods-use-this,no-unused-vars async _onCreate(item) { - // no-op - } - - /** - * Handler for the createMany method. This method is - * called after the createMany method has successfully created entities. - * @param {Array} createdItems - The created entities. - * @param {{ item: Object, error: ValidationError }[]} errorItems - Items that failed validation. - * @return {Promise} - * @async - * @protected - */ + return undefined; + } + // eslint-disable-next-line class-methods-use-this,no-unused-vars async _onCreateMany({ createdItems, errorItems }) { - // no-op - } - - /** - * General method to query entities by index keys. This method is used by other - * query methods to perform the actual query operation. It will use the index keys - * to find the appropriate index and query the entities. The query result will be - * transformed into model instances. - * - * @private - * @async - * @param {Object} keys - The index keys to use for the query. - * @param {Object} options - Additional options for the query. - * @returns {Promise|null>} - The query result. - * @throws {DataAccessError} - Throws an error if the keys are not provided, - * if options are invalid or if the query operation fails. - */ + return undefined; + } + + #toDbField(field) { + return toDbField(field, this.fieldMaps.toDbMap); + } + + #toDbRecord(record) { + return toDbRecord(record, this.fieldMaps.toDbMap); + } + + #toModelRecord(record) { + return fromDbRecord(record, this.fieldMaps.toModelMap); + } + + #buildSelect(attributes) { + if (!isNonEmptyArray(attributes)) { + return '*'; + } + return attributes.map((field) => this.#toDbField(field)).join(','); + } + + #getOrderField(indexName, keys) { + if (hasText(indexName)) { + const indexKeys = this.schema.getIndexKeys(indexName); + if (isNonEmptyArray(indexKeys)) { + return this.#toDbField(indexKeys[indexKeys.length - 1]); + } + } + + const keyNames = Object.keys(keys); + const defaultSortField = isNonEmptyArray(keyNames) + ? keyNames[keyNames.length - 1] + : 'updatedAt'; + return this.#toDbField(defaultSortField); + } + + #applyDefaults(record) { + const nextRecord = { ...record }; + const attributes = this.schema.getAttributes(); + Object.entries(attributes).forEach(([name, attribute]) => { + if (nextRecord[name] !== undefined || attribute.default === undefined) { + return; + } + + nextRecord[name] = typeof attribute.default === 'function' + ? attribute.default() + : attribute.default; + }); + return nextRecord; + } + + applyUpdateWatchers(record, updates) { + const nextRecord = { ...record }; + const nextUpdates = { ...updates }; + const changedKeys = Object.keys(updates); + if (changedKeys.length === 0) { + return { record: nextRecord, updates: nextUpdates }; + } + + const attributes = this.schema.getAttributes(); + Object.entries(attributes).forEach(([name, attribute]) => { + if (typeof attribute.set !== 'function') { + return; + } + + const { watch } = attribute; + const shouldApply = watch === '*' + || (Array.isArray(watch) && watch.some((key) => changedKeys.includes(key))); + + if (!shouldApply) { + return; + } + + const value = attribute.set(nextRecord[name], nextRecord); + nextRecord[name] = value; + nextUpdates[name] = value; + }); + return { record: nextRecord, updates: nextUpdates }; + } + + #applySetters(record) { + const nextRecord = { ...record }; + const attributes = this.schema.getAttributes(); + Object.entries(attributes).forEach(([name, attribute]) => { + if (typeof attribute.set !== 'function') { + return; + } + + const value = attribute.set(nextRecord[name], nextRecord); + if (value !== undefined) { + nextRecord[name] = value; + } + }); + return nextRecord; + } + + #validateItem(item) { + const attributes = this.schema.getAttributes(); + const errors = []; + + Object.entries(attributes).forEach(([name, attribute]) => { + const value = item[name]; + + if (attribute.required && (value === undefined || value === null)) { + errors.push(`${name} is required`); + return; + } + + if (value === undefined || value === null) { + return; + } + + if (Array.isArray(attribute.type) && !attribute.type.includes(value)) { + errors.push(`${name} is invalid`); + } else if (attribute.type === 'string' && typeof value !== 'string') { + errors.push(`${name} must be a string`); + } else if (attribute.type === 'number' && typeof value !== 'number') { + errors.push(`${name} must be a number`); + } else if (attribute.type === 'boolean' && typeof value !== 'boolean') { + errors.push(`${name} must be a boolean`); + } else if (attribute.type === 'list' && !Array.isArray(value)) { + errors.push(`${name} must be a list`); + } else if (attribute.type === 'map' && !isObject(value)) { + errors.push(`${name} must be a map`); + } + + if (typeof attribute.validate === 'function') { + try { + const result = attribute.validate(value, item); + if (result === false) { + errors.push(`${name} failed validation`); + } + } catch (e) { + errors.push(`${name} failed validation`); + } + } + }); + + if (errors.length > 0) { + throw new ValidationError(errors.join(', '), this); + } + } + + #prepareItem(item) { + let prepared = { ...item }; + prepared = this.#applyDefaults(prepared); + prepared = this.#applySetters(prepared); + this.#validateItem(prepared); + return prepared; + } + + #applyKeyFilters(query, keys) { + let filtered = query; + Object.entries(keys).forEach(([key, value]) => { + filtered = filtered.eq(this.#toDbField(key), value); + }); + return filtered; + } + + async #queryPage({ + keys, + options, + offset, + limit, + }) { + const select = this.#buildSelect(options.attributes); + const indexName = options.index || this.schema.findIndexNameByKeys(keys); + const index = this.schema.getIndexByName(indexName); + if (options.index && !index) { + this.#logAndThrowError(`Failed to query [${this.entityName}]: query proxy [${options.index}] not found`); + } + + const orderField = this.#getOrderField(indexName, keys); + let query = this.postgrestService + .from(this.tableName) + .select(select) + .order(orderField, { ascending: options.order === 'asc' }); + + query = this.#applyKeyFilters(query, keys); + if (isObject(options.between)) { + const betweenField = this.#toDbField(options.between.attribute); + query = query.gte(betweenField, options.between.start).lte(betweenField, options.between.end); + } + query = applyWhere(query, options.where, this.fieldMaps.toDbMap); + + if (Number.isInteger(limit)) { + query = query.range(offset, offset + limit - 1); + } else { + query = query.range(offset, offset + DEFAULT_PAGE_SIZE - 1); + } + + const { data, error } = await query; + if (error) { + this.#logAndThrowError('Failed to query', error); + } + + return (data || []).map((record) => this.#toModelRecord(record)); + } + async #queryByIndexKeys(keys, options = {}) { if (!isNonEmptyObject(keys)) { return this.#logAndThrowError(`Failed to query [${this.entityName}]: keys are required`); @@ -241,228 +339,194 @@ class BaseCollection { return this.#logAndThrowError(`Failed to query [${this.entityName}]: options must be an object`); } - const indexName = options.index || this.schema.findIndexNameByKeys(keys); - const index = this.entity.query[indexName]; - - if (!index) { - this.#logAndThrowError(`Failed to query [${this.entityName}]: query proxy [${indexName}] not found`); - } - try { - const queryOptions = { - order: options.order || 'desc', - ...options.limit && { limit: options.limit }, - ...options.attributes && { attributes: options.attributes }, - /* c8 ignore next */ - ...options.cursor && { cursor: options.cursor }, - }; + if (this.entity) { + const indexName = options.index || this.schema.findIndexNameByKeys(keys); + const index = this.entity.query[indexName]; + if (!index) { + this.#logAndThrowError(`Failed to query [${this.entityName}]: query proxy [${indexName}] not found`); + } - let query = index(keys); + const queryOptions = { + order: options.order || 'desc', + ...options.limit && { limit: options.limit }, + ...options.attributes && { attributes: options.attributes }, + ...options.cursor && { cursor: options.cursor }, + }; + + let query = index(keys); + if (isObject(options.between)) { + query = query.between( + { [options.between.attribute]: options.between.start }, + { [options.between.attribute]: options.between.end }, + ); + } + if (typeof options.where === 'function') { + query = query.where(options.where); + } - if (isObject(options.between)) { - query = query.between( - { [options.between.attribute]: options.between.start }, - { [options.between.attribute]: options.between.end }, - ); - } + let result = await query.go(queryOptions); + let allData = result.data; + const shouldFetchAllPages = options.fetchAllPages === true + || (options.fetchAllPages !== false && !options.limit); + if (shouldFetchAllPages) { + while (result.cursor) { + queryOptions.cursor = result.cursor; + // eslint-disable-next-line no-await-in-loop + result = await query.go(queryOptions); + allData = allData.concat(result.data); + } + } - // Apply where clause (FilterExpression) if provided - if (typeof options.where === 'function') { - query = query.where(options.where); - } + if (options.limit === 1) { + return allData.length ? this.#createInstance(allData[0]) : null; + } - // execute the initial query - let result = await query.go(queryOptions); - let allData = result.data; + const instances = this.#createInstances(allData); + return options.returnCursor + ? { data: instances, cursor: result.cursor || null } + : instances; + } - // Smart pagination behavior: - // - fetchAllPages: true → Always paginate through all results - // - fetchAllPages: false → Only fetch first page - // - undefined → Auto-paginate when no limit specified, respect limits otherwise const shouldFetchAllPages = options.fetchAllPages === true || (options.fetchAllPages !== false && !options.limit); + const shouldReturnCursor = options.returnCursor === true; + const limit = Number.isInteger(options.limit) ? options.limit : undefined; + + let offset = decodeCursor(options.cursor); + let allRows = []; + let cursor = null; if (shouldFetchAllPages) { - while (result.cursor) { - queryOptions.cursor = result.cursor; + const pageSize = limit || DEFAULT_PAGE_SIZE; + let keepGoing = true; + + while (keepGoing) { // eslint-disable-next-line no-await-in-loop - result = await query.go(queryOptions); - allData = allData.concat(result.data); + const pageRows = await this.#queryPage({ + keys, + options, + offset, + limit: pageSize, + }); + allRows = allRows.concat(pageRows); + if (pageRows.length < pageSize) { + keepGoing = false; + cursor = null; + } else { + offset += pageSize; + } + } + } else { + const pageRows = await this.#queryPage({ + keys, + options, + offset, + limit, + }); + allRows = pageRows; + if (limit && pageRows.length === limit) { + cursor = encodeCursor(offset + limit); } } - // Return cursor when explicitly requested via returnCursor option - const shouldReturnCursor = options.returnCursor === true; - if (options.limit === 1) { - return allData.length ? this.#createInstance(allData[0]) : null; - } else { - const instances = this.#createInstances(allData); - /* c8 ignore next 2 */ - return shouldReturnCursor - ? { data: instances, cursor: result.cursor || null } - : instances; + return allRows.length ? this.#createInstance(allRows[0]) : null; } + + const instances = this.#createInstances(allRows); + return shouldReturnCursor ? { data: instances, cursor } : instances; } catch (error) { + if (error instanceof DataAccessError) { + throw error; + } return this.#logAndThrowError('Failed to query', error); } } - /** - * Finds all entities in the collection. Requires an index named "all" with a partition key - * named "pk" with a static value of "ALL_". - * @async - * @param {Object} [sortKeys] - The sort keys to use for the query. - * @param {Object} [options] - Additional options for the query. - * @return {Promise|null>} - */ async all(sortKeys = {}, options = {}) { - const keys = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys }; + const keys = this.entity + ? { pk: entityNameToAllPKValue(this.entityName), ...sortKeys } + : sortKeys; return this.#queryByIndexKeys(keys, options); } - /** - * Finds entities by a set of index keys. Index keys are used to query entities by - * a specific index defined in the entity schema. The index keys must match the - * fields defined in the index. - * @param {Object} keys - The index keys to use for the query. - * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query. - * @return {Promise>} - A promise that resolves to an array of model instances. - * @throws {Error} - Throws an error if the index keys are not provided or if the index - * is not found. - * @async - */ async allByIndexKeys(keys, options = {}) { return this.#queryByIndexKeys(keys, options); } - /** - * Finds a single entity from the "all" index. Requires an "all" index to be added to the - * entity schema via the schema builder. - * @async - * @param {Object} [sortKeys] - The sort keys to use for the query. - * @param {QueryOptions} [options] - Additional options for the query. - * Additional options for the query. - * @return {Promise} - * @throws {DataAccessError} - Throws an error if the sort keys are not provided. - */ async findByAll(sortKeys = {}, options = {}) { if (!isObject(sortKeys)) { const message = `Failed to find by all [${this.entityName}]: sort keys must be an object`; this.log.error(message); throw new DataAccessError(message); } - - const keys = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys }; + const keys = this.entity + ? { pk: entityNameToAllPKValue(this.entityName), ...sortKeys } + : sortKeys; return this.#queryByIndexKeys(keys, { ...options, limit: 1 }); } - /** - * Finds an entity by its ID. This will only work if the entity's schema - * did not override the main table primary key via schema builder. - * @async - * @param {string} id - The unique identifier of the entity to be found. - * @returns {Promise} - A promise that resolves to an instance of - * the model if found, otherwise null. - * @throws {ValidationError} - Throws an error if the ID is not provided. - */ async findById(id) { guardId(this.idName, id, this.entityName); - - const record = await this.entity.get({ [this.idName]: id }).go(); - - return this.#createInstance(record?.data); + if (this.entity) { + const record = await this.entity.get({ [this.idName]: id }).go(); + return this.#createInstance(record?.data); + } + return this.findByIndexKeys({ [this.idName]: id }); } - /** - * Checks if an entity exists by its ID. - * @param {string} id - The UUID of the entity to check. - * @return {Promise} - A promise that resolves to true if the entity exists, - * otherwise false. - * @throws {ValidationError} - Throws an error if the ID is not provided. - */ async existsById(id) { guardId(this.idName, id, this.entityName); + if (this.entity) { + const record = await this.entity.get({ [this.idName]: id }).go({ + attributes: [this.idName], + }); + return isNonEmptyObject(record?.data); + } + const item = await this.findByIndexKeys( + { [this.idName]: id }, + { attributes: [this.idName] }, + ); + return isNonEmptyObject(item); + } - const record = await this.entity.get({ [this.idName]: id }).go({ - attributes: [this.idName], - }); - - return isNonEmptyObject(record?.data); - } - - /** - * Retrieves multiple entities by their IDs in a single batch operation. - * This method is more efficient than calling findById multiple times. - * - * @async - * @param {Array} ids - An array of entity IDs to retrieve. - * @param {{attributes?: string[]}} [options] - Additional options for the query. - * @returns {Promise<{data: Array, unprocessed: Array}>} - A promise that - * resolves - * to an object containing: - * - data: Array of found model instances - * - unprocessed: Array of IDs that couldn't be processed (due to throttling, etc.) - * @throws {DataAccessError} - Throws an error if the IDs are not provided or if the batch - * operation fails. - */ async batchGetByKeys(keys, options = {}) { guardArray('keys', keys, this.entityName, 'any'); try { - const goOptions = {}; - - // Add attributes if specified - if (options.attributes !== undefined) { - goOptions.attributes = options.attributes; + if (this.entity) { + const goOptions = {}; + if (options.attributes !== undefined) { + goOptions.attributes = options.attributes; + } + const result = await this.entity.get(keys).go(goOptions); + const data = result.data + .map((record) => this.#createInstance(record)) + .filter((entity) => entity !== null); + const unprocessed = result.unprocessed + ? result.unprocessed.map((item) => item) + : []; + return { data, unprocessed }; } - const result = await this.entity.get( - keys, - ).go(goOptions); - - // Process found entities - const data = result.data - .map((record) => this.#createInstance(record)) - .filter((entity) => entity !== null); - - // Extract unprocessed keys - /* c8 ignore next 3 */ - const unprocessed = result.unprocessed - ? result.unprocessed.map((item) => item) - : []; - - return { data, unprocessed }; + const records = await Promise.all( + keys.map((key) => this.findByIndexKeys(key, options)), + ); + return { + data: records.filter((record) => record !== null), + unprocessed: [], + }; } catch (error) { this.log.error(`Failed to batch get by keys [${this.entityName}]`, error); throw new DataAccessError('Failed to batch get by keys', this, error); } } - /** - * Finds a single entity by index keys. - * @param {Object} keys - The index keys to use for the query. - * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query. - * @returns {Promise} - A promise that resolves to the model instance or null. - * @throws {DataAccessError} - Throws an error if retrieving the entity fails. - * @async - */ async findByIndexKeys(keys, options = {}) { return this.#queryByIndexKeys(keys, { ...options, limit: 1 }); } - /** - * Creates a new entity in the collection and directly persists it to the database. - * There is no need to call the save method (which is for updates only) after creating - * the entity. - * @async - * @param {Object} item - The data for the entity to be created. - * @param {Object} [options] - Additional options for the creation process. - * @param {boolean} [options.upsert=false] - Whether to perform an upsert operation. - * @returns {Promise} - A promise that resolves to the created model instance. - * @throws {DataAccessError} - Throws an error if the data is invalid or if the - * creation process fails. - */ async create(item, { upsert = false } = {}) { if (!isNonEmptyObject(item)) { const message = `Failed to create [${this.entityName}]: data is required`; @@ -471,59 +535,37 @@ class BaseCollection { } try { - const record = upsert - ? await this.entity.put(item).go() - : await this.entity.create(item).go(); + if (this.entity) { + const record = upsert + ? await this.entity.put(item).go() + : await this.entity.create(item).go(); + const instance = this.#createInstance(record.data); + this.#invalidateCache(); + await this.#onCreate(instance); + return instance; + } - const instance = this.#createInstance(record.data); + const prepared = this.#prepareItem(item); + const payload = this.#toDbRecord(prepared); + const conflictKey = this.#toDbField(this.idName); - this.#invalidateCache(); + let query = this.postgrestService.from(this.tableName); + query = upsert ? query.upsert(payload, { onConflict: conflictKey }) : query.insert(payload); + const { data, error } = await query.select().maybeSingle(); - await this.#onCreate(instance); + if (error) { + return this.#logAndThrowError('Failed to create', error); + } + const instance = this.#createInstance(this.#toModelRecord(data)); + this.#invalidateCache(); + await this.#onCreate(instance); return instance; } catch (error) { return this.#logAndThrowError('Failed to create', error); } } - /** - * Validates and batches items for batch operations. - * @private - * @param {Array} items - Items to be validated. - * @returns {Object} - An object containing validated items and error items. - */ - #validateItems(items) { - const validatedItems = []; - const errorItems = []; - - items.forEach((item) => { - try { - const { Item } = this.entity.put(item).params(); - validatedItems.push({ ...removeElectroProperties(Item), ...item }); - } catch (error) { - if (error instanceof ElectroValidationError) { - errorItems.push({ item, error: new ValidationError('Validation error', this, error) }); - } - } - }); - - return { validatedItems, errorItems }; - } - - /** - * Creates multiple entities in the collection and directly persists them to the database in - * a batch write operation. Batches are written in parallel and are limited to 25 items per batch. - * - * @async - * @param {Array} newItems - An array of data for the entities to be created. - * @param {BaseModel} [parent] - Optional parent entity that these items are associated with. - * @return {Promise<{ createdItems: BaseModel[], - * errorItems: { item: Object, error: ValidationError }[] }>} - A promise that resolves to - * an object containing the created items and any items that failed validation. - * @throws {DataAccessError} - Throws an error if the items are not provided or if the - * creation process fails. - */ async createMany(newItems, parent = null) { if (!isNonEmptyArray(newItems)) { const message = `Failed to create many [${this.entityName}]: items must be a non-empty array`; @@ -532,18 +574,71 @@ class BaseCollection { } try { - const { validatedItems, errorItems } = this.#validateItems(newItems); + if (this.entity) { + const validatedItems = []; + const errorItems = []; + newItems.forEach((item) => { + try { + const { Item } = this.entity.put(item).params(); + validatedItems.push({ ...removeElectroProperties(Item), ...item }); + } catch (error) { + if (error instanceof ElectroValidationError) { + errorItems.push({ item, error: new ValidationError('Validation error', this, error) }); + } + } + }); - if (validatedItems.length > 0) { - const response = await this.entity.put(validatedItems).go(); + if (validatedItems.length > 0) { + const response = await this.entity.put(validatedItems).go(); + if (isNonEmptyArray(response?.unprocessed)) { + this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); + } + } - if (isNonEmptyArray(response?.unprocessed)) { - this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); + const createdItems = this.#createInstances(validatedItems); + if (isNonEmptyObject(parent)) { + createdItems.forEach((record) => { + if (!isValidParent(parent, record)) { + this.log.warn(`Failed to associate parent with child [${this.entityName}]: parent is invalid`); + return; + } + // eslint-disable-next-line no-underscore-dangle,no-param-reassign + record._accessorCache[`get${parent.schema.getModelName()}`] = parent; + }); } + this.#invalidateCache(); + await this.#onCreateMany({ createdItems, errorItems }); + return { createdItems, errorItems }; } - const createdItems = this.#createInstances(validatedItems); + const validatedItems = []; + const errorItems = []; + + newItems.forEach((item) => { + try { + validatedItems.push(this.#prepareItem(item)); + } catch (error) { + if (error instanceof ValidationError) { + errorItems.push({ item, error }); + } else { + throw error; + } + } + }); + if (validatedItems.length > 0) { + const payload = validatedItems.map((item) => this.#toDbRecord(item)); + const { error } = await this.postgrestService + .from(this.tableName) + .insert(payload) + .select(); + + if (error) { + return this.#logAndThrowError('Failed to create many', error); + } + } + + const createdItems = this.#createInstances(validatedItems); if (isNonEmptyObject(parent)) { createdItems.forEach((record) => { if (!isValidParent(parent, record)) { @@ -556,26 +651,36 @@ class BaseCollection { } this.#invalidateCache(); - await this.#onCreateMany({ createdItems, errorItems }); - return { createdItems, errorItems }; } catch (error) { return this.#logAndThrowError('Failed to create many', error); } } - /** - * Updates a collection of entities in the database using a batch write (put) operation. - * - * @async - * @param {Array} items - An array of model instances to be updated. - * @return {Promise} - A promise that resolves when the update operation is complete. - * @throws {DataAccessError} - Throws an error if the items are not provided or if the - * update operation fails. - * - * @protected - */ + async updateByKeys(keys, updates) { + if (!isNonEmptyObject(keys) || !isNonEmptyObject(updates)) { + throw new DataAccessError(`Failed to update [${this.entityName}]: keys and updates are required`); + } + + if (this.entity) { + const patch = this.entity.patch(keys); + Object.entries(updates).forEach(([key, value]) => { + patch.set({ [key]: value }); + }); + await patch.go(); + return; + } + + let query = this.postgrestService.from(this.tableName).update(this.#toDbRecord(updates)); + query = this.#applyKeyFilters(query, keys); + + const { error } = await query.select().maybeSingle(); + if (error) { + throw new DataAccessError('Failed to update entity', this, error); + } + } + async _saveMany(items) { if (!isNonEmptyArray(items)) { const message = `Failed to save many [${this.entityName}]: items must be a non-empty array`; @@ -584,33 +689,38 @@ class BaseCollection { } try { - const updates = items.map((item) => item.record); - const response = await this.entity.put(updates).go(); - - const now = new Date().toISOString(); - items.forEach((item) => { - const { record } = item; - record.updatedAt = now; - }); - - if (isNonEmptyArray(response.unprocessed)) { - this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); + if (this.entity) { + const updates = items.map((item) => item.record); + const response = await this.entity.put(updates).go(); + const now = new Date().toISOString(); + items.forEach((item) => { + const { record } = item; + record.updatedAt = now; + }); + if (isNonEmptyArray(response.unprocessed)) { + this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); + } + this.#invalidateCache(); + return undefined; } - return this.#invalidateCache(); + await Promise.all( + items.map(async (item) => { + const keys = item.generateCompositeKeys + ? item.generateCompositeKeys() + : { [this.idName]: item.getId() }; + const { updates } = this.applyUpdateWatchers(item.record, item.record); + await this.updateByKeys(keys, updates); + }), + ); + + this.#invalidateCache(); + return undefined; } catch (error) { return this.#logAndThrowError('Failed to save many', error); } } - /** - * Removes all records of this entity based on the provided IDs. This will perform a batch - * delete operation. This operation does not remove dependent records. - * @param {Array} ids - An array of IDs to remove. - * @return {Promise} - A promise that resolves when the removal operation is complete. - * @throws {DataAccessError} - Throws an error if the IDs are not provided or if the - * removal operation fails. - */ async removeByIds(ids) { if (!isNonEmptyArray(ids)) { const message = `Failed to remove [${this.entityName}]: ids must be a non-empty array`; @@ -619,37 +729,28 @@ class BaseCollection { } try { - // todo: consider removing dependent records + if (this.entity) { + await this.entity.delete(ids.map((id) => ({ [this.idName]: id }))).go(); + this.#invalidateCache(); + return undefined; + } - await this.entity.delete(ids.map((id) => ({ [this.idName]: id }))).go(); + const { error } = await this.postgrestService + .from(this.tableName) + .delete() + .in(this.#toDbField(this.idName), ids); - return this.#invalidateCache(); + if (error) { + return this.#logAndThrowError('Failed to remove by IDs', error); + } + + this.#invalidateCache(); + return undefined; } catch (error) { 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`; @@ -666,9 +767,25 @@ class BaseCollection { }); try { - await this.entity.delete(keys).go(); + if (this.entity) { + await this.entity.delete(keys).go(); + this.log.info(`Removed ${keys.length} items for [${this.entityName}]`); + this.#invalidateCache(); + return undefined; + } + + await Promise.all(keys.map(async (key) => { + let query = this.postgrestService.from(this.tableName).delete(); + query = this.#applyKeyFilters(query, key); + const { error } = await query; + if (error) { + throw error; + } + })); + this.log.info(`Removed ${keys.length} items for [${this.entityName}]`); - return this.#invalidateCache(); + this.#invalidateCache(); + return undefined; } catch (error) { return this.#logAndThrowError('Failed to remove by index keys', error); } 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 68e523b01..28e01ef2b 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 @@ -50,15 +50,15 @@ class BaseModel { /** * Constructs an instance of BaseModel. * @constructor - * @param {Object} electroService - The ElectroDB service used for managing entities. + * @param {Object} postgrestService - The PostgREST client used for managing entities. * @param {EntityRegistry} entityRegistry - The registry holding entities, their schema * and collection. * @param {Schema} schema - The schema for the entity. * @param {Object} record - The initial data for the entity instance. * @param {Object} log - A log for capturing logging information. */ - constructor(electroService, entityRegistry, schema, record, log) { - this.electroService = electroService; + constructor(postgrestService, entityRegistry, schema, record, log) { + this.postgrestService = postgrestService; this.entityRegistry = entityRegistry; this.schema = schema; this.record = record; @@ -68,9 +68,8 @@ class BaseModel { this.idName = entityNameToIdName(this.entityName); this.collection = entityRegistry.getCollection(schema.getCollectionName()); - this.entity = electroService.entities[this.entityName]; - this.patcher = new Patcher(this.entity, this.schema, this.record); + this.patcher = new Patcher(this.collection, this.schema, this.record); this._accessorCache = {}; @@ -283,7 +282,15 @@ class BaseModel { await Promise.all(removePromises); - await this.entity.remove(this.generateCompositeKeys()).go(); + if (this.collection && typeof this.collection.removeByIndexKeys === 'function') { + await this.collection.removeByIndexKeys([this.generateCompositeKeys()]); + } else if (this.postgrestService?.entities?.[this.entityName]?.remove) { + await this.postgrestService.entities[this.entityName] + .remove(this.generateCompositeKeys()) + .go(); + } else { + throw new DataAccessError(`No remove strategy available for ${this.entityName}`, this); + } this.#invalidateCache(); diff --git a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js index d9b4abdbd..0e7c95946 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 @@ -88,7 +88,7 @@ class EntityRegistry { * Constructs an instance of EntityRegistry. * @constructor * @param {Object} services - Dictionary of services keyed by datastore type. - * @param {Object} services.dynamo - The ElectroDB service instance for DynamoDB operations. + * @param {Object} services.postgrest - The PostgREST client instance for Postgres operations. * @param {{s3Client: S3Client, s3Bucket: string}|null} [services.s3] - S3 service configuration. * @param {Object} log - A logger for capturing and logging information. */ @@ -103,14 +103,14 @@ class EntityRegistry { /** * Initializes the collections managed by the EntityRegistry. * This method creates instances of each collection and stores them in an internal map. - * ElectroDB-based collections are initialized with the dynamo service. + * PostgREST-based collections are initialized with the postgrest service. * Configuration is handled specially as it's a standalone S3-based collection. * @private */ #initialize() { - // Initialize ElectroDB-based collections + // Initialize PostgREST-based collections Object.values(EntityRegistry.entities).forEach(({ collection: Collection, schema }) => { - const collection = new Collection(this.services.dynamo, this, schema, this.log); + const collection = new Collection(this.services.postgrest, this, schema, this.log); this.collections.set(Collection.COLLECTION_NAME, collection); }); 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 4ce923679..35ae18645 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 @@ -36,6 +36,7 @@ const DEFAULT_SERVICE_NAME = 'SpaceCat'; */ const ID_ATTRIBUTE_DATA = { type: 'string', + postgrestField: 'id', required: true, readOnly: true, // https://electrodb.dev/en/modeling/attributes/#default diff --git a/packages/spacecat-shared-data-access/src/models/key-event/key-event.collection.js b/packages/spacecat-shared-data-access/src/models/key-event/key-event.collection.js index 87cdc08ba..d3c6fbfa3 100644 --- a/packages/spacecat-shared-data-access/src/models/key-event/key-event.collection.js +++ b/packages/spacecat-shared-data-access/src/models/key-event/key-event.collection.js @@ -11,6 +11,7 @@ */ import BaseCollection from '../base/base.collection.js'; +import DataAccessError from '../../errors/data-access.error.js'; /** * KeyEventCollection - A collection class responsible for managing KeyEvent entities. @@ -22,7 +23,25 @@ import BaseCollection from '../base/base.collection.js'; class KeyEventCollection extends BaseCollection { static COLLECTION_NAME = 'KeyEventCollection'; - // add custom methods here + #throwDeprecated() { + throw new DataAccessError('KeyEvent is deprecated in data-access v3', this); + } + + async all() { return this.#throwDeprecated(); } + + async allByIndexKeys() { return this.#throwDeprecated(); } + + async findById() { return this.#throwDeprecated(); } + + async findByIndexKeys() { return this.#throwDeprecated(); } + + async create() { return this.#throwDeprecated(); } + + async createMany() { return this.#throwDeprecated(); } + + async removeByIds() { return this.#throwDeprecated(); } + + async removeByIndexKeys() { return this.#throwDeprecated(); } } export default KeyEventCollection; diff --git a/packages/spacecat-shared-data-access/src/models/latest-audit/latest-audit.collection.js b/packages/spacecat-shared-data-access/src/models/latest-audit/latest-audit.collection.js index 6dc728305..3d64eb794 100755 --- a/packages/spacecat-shared-data-access/src/models/latest-audit/latest-audit.collection.js +++ b/packages/spacecat-shared-data-access/src/models/latest-audit/latest-audit.collection.js @@ -10,8 +10,10 @@ * governing permissions and limitations under the License. */ -import BaseCollection from '../base/base.collection.js'; +import { isNonEmptyArray } from '@adobe/spacecat-shared-utils'; +import DataAccessError from '../../errors/data-access.error.js'; import { guardId, guardString } from '../../util/index.js'; +import BaseCollection from '../base/base.collection.js'; /** * LatestAuditCollection - A collection class responsible for managing LatestAudit entities. @@ -23,8 +25,81 @@ import { guardId, guardString } from '../../util/index.js'; class LatestAuditCollection extends BaseCollection { static COLLECTION_NAME = 'LatestAuditCollection'; - async create(item) { - return super.create(item, { upsert: true }); + // LatestAudit is a virtual view in v3; writes are not supported. + // eslint-disable-next-line class-methods-use-this + async create() { + throw new DataAccessError('LatestAudit is derived from Audit in v3 and cannot be created directly', this); + } + + // eslint-disable-next-line class-methods-use-this + async createMany() { + throw new DataAccessError('LatestAudit is derived from Audit in v3 and cannot be created directly', this); + } + + static #groupLatest(items, groupFields) { + const grouped = new Map(); + items.forEach((item) => { + const key = groupFields.map((field) => item.record[field]).join('#'); + const existing = grouped.get(key); + if (!existing || existing.getAuditedAt() < item.getAuditedAt()) { + grouped.set(key, item); + } + }); + return [...grouped.values()]; + } + + async #allAuditsByKeys(keys, options = {}) { + const auditCollection = this.entityRegistry.getCollection('AuditCollection'); + return auditCollection.allByIndexKeys(keys, { + ...options, + fetchAllPages: true, + order: 'desc', + returnCursor: false, + }); + } + + async all(sortKeys = {}, options = {}) { + return this.allByIndexKeys(sortKeys, options); + } + + async findByAll(sortKeys = {}, options = {}) { + return this.findByIndexKeys(sortKeys, options); + } + + async findByIndexKeys(keys, options = {}) { + const auditCollection = this.entityRegistry.getCollection('AuditCollection'); + + if (keys.siteId && keys.auditType) { + return auditCollection.findByIndexKeys(keys, { ...options, order: 'desc' }); + } + + const audits = await this.#allAuditsByKeys(keys, options); + if (!isNonEmptyArray(audits)) { + return null; + } + + const groupFields = keys.siteId ? ['auditType'] : ['siteId', 'auditType']; + const latest = LatestAuditCollection.#groupLatest(audits, groupFields); + return latest[0] || null; + } + + async allByIndexKeys(keys, options = {}) { + const audits = await this.#allAuditsByKeys(keys, options); + if (!isNonEmptyArray(audits)) { + return options.returnCursor ? { data: [], cursor: null } : []; + } + + let groupFields = ['siteId', 'auditType']; + if (keys.siteId && !keys.auditType) { + groupFields = ['auditType']; + } else if (!keys.siteId && keys.auditType) { + groupFields = ['siteId']; + } + + const latest = LatestAuditCollection.#groupLatest(audits, groupFields); + const limited = Number.isInteger(options.limit) ? latest.slice(0, options.limit) : latest; + + return options.returnCursor ? { data: limited, cursor: null } : limited; } async allByAuditType(auditType) { diff --git a/packages/spacecat-shared-data-access/src/service/index.d.ts b/packages/spacecat-shared-data-access/src/service/index.d.ts index c3b39f1e1..6afa3ef55 100644 --- a/packages/spacecat-shared-data-access/src/service/index.d.ts +++ b/packages/spacecat-shared-data-access/src/service/index.d.ts @@ -11,10 +11,16 @@ */ interface DataAccessConfig { - tableNameData: string; + postgrestUrl: string; + postgrestSchema?: string; + postgrestApiKey?: string; + postgrestHeaders?: object; + s3Bucket?: string; + region?: string; } export function createDataAccess( config: DataAccessConfig, logger: object, + client?: object, ): object; diff --git a/packages/spacecat-shared-data-access/src/service/index.js b/packages/spacecat-shared-data-access/src/service/index.js index 54e90643a..b33bc0bfc 100644 --- a/packages/spacecat-shared-data-access/src/service/index.js +++ b/packages/spacecat-shared-data-access/src/service/index.js @@ -10,10 +10,8 @@ * governing permissions and limitations under the License. */ -import { DynamoDB } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'; import { S3Client } from '@aws-sdk/client-s3'; -import { Service } from 'electrodb'; +import { PostgrestClient } from '@supabase/postgrest-js'; import { instrumentAWSClient } from '@adobe/spacecat-shared-utils'; import { EntityRegistry } from '../models/index.js'; @@ -23,47 +21,31 @@ export * from '../errors/index.js'; export * from '../models/index.js'; export * from '../util/index.js'; -let defaultDynamoDBClient; -const documentClientCache = new WeakMap(); - -const createRawClient = (client = undefined) => { - const rawClient = client || (() => { - if (!defaultDynamoDBClient) { - defaultDynamoDBClient = new DynamoDB(); - } - return defaultDynamoDBClient; - })(); - - let documentClient = documentClientCache.get(rawClient); - if (!documentClient) { - documentClient = DynamoDBDocument.from(instrumentAWSClient(rawClient), { - marshallOptions: { - convertEmptyValues: true, - removeUndefinedValues: true, - }, - }); - documentClientCache.set(rawClient, documentClient); +const createPostgrestService = (config, client = undefined) => { + if (client) { + return client; } - return documentClient; -}; + const { + postgrestUrl, + postgrestSchema = 'public', + postgrestApiKey, + postgrestHeaders = {}, + } = config; + + if (!postgrestUrl) { + throw new Error('postgrestUrl is required to create data access'); + } -const createElectroService = (client, config, log) => { - const { tableNameData: table } = config; - /* c8 ignore start */ - const logger = (event) => { - log.debug(JSON.stringify(event, null, 4)); + const headers = { + ...postgrestHeaders, + ...postgrestApiKey ? { apikey: postgrestApiKey, Authorization: `Bearer ${postgrestApiKey}` } : {}, }; - /* c8 ignore end */ - - return new Service( - EntityRegistry.getEntities(), - { - client, - table, - logger, - }, - ); + + return new PostgrestClient(postgrestUrl, { + schema: postgrestSchema, + headers, + }); }; /** @@ -91,30 +73,29 @@ const createS3Service = (config) => { * Creates a services dictionary containing all datastore services. * Each collection can declare which service it needs via its DATASTORE_TYPE. * - * @param {object} electroService - The ElectroDB service for DynamoDB operations + * @param {PostgrestClient} postgrestService - PostgREST client * @param {object} config - Configuration object - * @returns {object} Services dictionary with dynamo and s3 services + * @returns {object} Services dictionary with postgrest and s3 services */ -const createServices = (electroService, config) => ({ - dynamo: electroService, +const createServices = (postgrestService, config) => ({ + postgrest: postgrestService, s3: createS3Service(config), }); /** - * Creates a data access layer for interacting with DynamoDB using ElectroDB. + * Creates a data access layer for interacting with Postgres via PostgREST. * - * @param {{tableNameData: string, s3Bucket?: string, region?: string}} config - Configuration - * object containing table name and optional S3 configuration + * @param {{postgrestUrl: string, postgrestSchema?: string, postgrestApiKey?: string, + * postgrestHeaders?: object, s3Bucket?: string, region?: string}} config - Configuration object * @param {object} log - Logger instance, defaults to console - * @param {DynamoDB} [client] - Optional custom DynamoDB client instance + * @param {PostgrestClient} [client] - Optional custom Postgrest client instance * @returns {object} Data access collections for interacting with entities */ export const createDataAccess = (config, log = console, client = undefined) => { registerLogger(log); - const rawClient = createRawClient(client); - const electroService = createElectroService(rawClient, config, log); - const services = createServices(electroService, config); + const postgrestService = createPostgrestService(config, client); + const services = createServices(postgrestService, config); const entityRegistry = new EntityRegistry(services, log); return entityRegistry.getCollections(); diff --git a/packages/spacecat-shared-data-access/src/util/index.js b/packages/spacecat-shared-data-access/src/util/index.js index 9c5a7000d..9ee227ff8 100644 --- a/packages/spacecat-shared-data-access/src/util/index.js +++ b/packages/spacecat-shared-data-access/src/util/index.js @@ -33,6 +33,6 @@ export { * @enum {string} */ export const DATASTORE_TYPE = Object.freeze({ - DYNAMO: 'dynamo', + POSTGREST: 'postgrest', S3: 's3', }); diff --git a/packages/spacecat-shared-data-access/src/util/patcher.js b/packages/spacecat-shared-data-access/src/util/patcher.js old mode 100755 new mode 100644 index d3c7a9e1c..8a926bb82 --- a/packages/spacecat-shared-data-access/src/util/patcher.js +++ b/packages/spacecat-shared-data-access/src/util/patcher.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { isNonEmptyArray, isObject } from '@adobe/spacecat-shared-utils'; +import { isObject } from '@adobe/spacecat-shared-utils'; import ValidationError from '../errors/validation.error.js'; @@ -26,13 +26,6 @@ import { guardString, } from './index.js'; -/** - * Checks if a property is read-only and throws an error if it is. - * @param {string} propertyName - The name of the property to check. - * @param {Object} attribute - The attribute to check. - * @throws {Error} - Throws an error if the property is read-only. - * @private - */ const checkReadOnly = (propertyName, attribute) => { if (attribute.readOnly) { throw new ValidationError(`The property ${propertyName} is read-only and cannot be updated.`); @@ -48,137 +41,65 @@ const checkUpdatesAllowed = (schema) => { class Patcher { /** * Creates a new Patcher instance for an entity. - * @param {object} entity - The entity backing the record. + * @param {object} collection - The backing collection instance. * @param {Schema} schema - The schema for the entity. * @param {object} record - The record to patch. */ - constructor(entity, schema, record) { - this.entity = entity; + constructor(collection, schema, record) { + this.collection = collection; this.schema = schema; this.record = record; this.entityName = schema.getEntityName(); - this.model = entity.model; this.idName = schema.getIdName(); - // holds the previous value of updated attributes this.previous = {}; - - // holds the updates to the attributes this.updates = {}; + this.legacyEntity = collection && typeof collection.patch === 'function' + ? collection + : null; this.patchRecord = null; } - /** - * Checks if a property is nullable. - * @param {string} propertyName - The name of the property to check. - * @return {boolean} True if the property is nullable, false otherwise. - * @private - */ #isAttributeNullable(propertyName) { - return !this.model.schema.attributes[propertyName]?.required; + return !this.schema.getAttribute(propertyName)?.required; } - /** - * Composite keys have to be provided to ElectroDB in order to update a record across - * multiple indexes. This method retrieves the composite values for the entity from - * the schema indexes and filters out any values that are being updated. - * @return {{}} - An object containing the composite values for the entity. - * @private - */ - #getCompositeValues() { - const { indexes } = this.model; - const result = {}; - - const processComposite = (index, compositeType) => { - const compositeArray = index[compositeType]?.facets; - if (isNonEmptyArray(compositeArray)) { - compositeArray.forEach((compositeKey) => { - if ( - !Object.keys(this.updates).includes(compositeKey) - && this.record[compositeKey] !== undefined - ) { - result[compositeKey] = this.record[compositeKey]; - } - }); - } - }; - - Object.values(indexes).forEach((index) => { - processComposite(index, 'pk'); - processComposite(index, 'sk'); - }); - - return result; - } - - /** - * Sets a property on the record and updates the patch record. - * @param {string} attribute - The attribute to set. - * @param {any} value - The value to set for the property. - * @private - */ - #set(attribute, value) { - this.patchRecord = this.#getPatchRecord().set({ [attribute.name]: value }); - - const transmutedValue = attribute.get(value, () => {}); + #set(propertyName, attribute, value) { const update = { - [attribute.name]: { - previous: this.record[attribute.name], - current: transmutedValue, + [propertyName]: { + previous: this.record[propertyName], + current: value, }, }; - // update the record with the update value for later save - this.record[attribute.name] = transmutedValue; - - // remember the update operation with the previous and current value + this.record[propertyName] = value; this.updates = { ...this.updates, ...update }; + + if (this.legacyEntity) { + if (!this.patchRecord) { + this.patchRecord = this.legacyEntity.patch(this.#getPrimaryKeyValues()); + } + this.patchRecord = this.patchRecord.set({ [propertyName]: value }); + } } - /** - * Gets the primary key values for the entity from the schema's primary index. - * This supports composite primary keys (e.g., siteId + url). - * @return {Object} - An object containing the primary key values. - * @private - */ #getPrimaryKeyValues() { const primaryKeys = this.schema.getIndexKeys('primary'); - if (isNonEmptyArray(primaryKeys)) { + if (Array.isArray(primaryKeys) && primaryKeys.length > 0) { return primaryKeys.reduce((acc, key) => { acc[key] = this.record[key]; return acc; }, {}); } - // Fallback to default id name return { [this.idName]: this.record[this.idName] }; } - /** - * Gets the patch record for the entity. If it does not exist, it will be created. - * @return {Object} - The patch record for the entity. - * @private - */ - #getPatchRecord() { - if (!this.patchRecord) { - this.patchRecord = this.entity.patch(this.#getPrimaryKeyValues()); - } - return this.patchRecord; - } - - /** - * Patches a value for a given property on the entity. This method will validate the value - * against the schema and throw an error if the value is invalid. If the value is declared as - * a reference, it will validate the ID format. - * @param {string} propertyName - The name of the property to patch. - * @param {any} value - The value to patch. - * @param {boolean} [isReference=false] - Whether the value is a reference to another entity. - */ patchValue(propertyName, value, isReference = false) { checkUpdatesAllowed(this.schema); - const attribute = this.model.schema?.attributes[propertyName]; + const attribute = this.schema.getAttribute(propertyName); if (!isObject(attribute)) { throw new ValidationError(`Property ${propertyName} does not exist on entity ${this.entityName}.`); } @@ -189,6 +110,8 @@ class Patcher { if (isReference) { guardId(propertyName, value, this.entityName, nullable); + } else if (Array.isArray(attribute.type)) { + guardEnum(propertyName, value, attribute.type, this.entityName, nullable); } else { switch (attribute.type) { case 'any': @@ -220,14 +143,9 @@ class Patcher { } } - this.#set(attribute, value); + this.#set(propertyName, attribute, value); } - /** - * Saves the current state of the entity to the database. - * @return {Promise} - * @throws {Error} - Throws an error if the save operation fails. - */ async save() { checkUpdatesAllowed(this.schema); @@ -235,11 +153,35 @@ class Patcher { return; } - const compositeValues = this.#getCompositeValues(); - await this.#getPatchRecord() - .composite(compositeValues) - .go(); - this.record.updatedAt = new Date().toISOString(); + const previousUpdatedAt = this.record.updatedAt; + const now = new Date().toISOString(); + this.record.updatedAt = now; + this.updates.updatedAt = { + previous: previousUpdatedAt, + current: now, + }; + + const keys = this.#getPrimaryKeyValues(); + const updates = Object.keys(this.updates).reduce((acc, key) => { + acc[key] = this.updates[key].current; + return acc; + }, {}); + + if (this.collection + && typeof this.collection.applyUpdateWatchers === 'function' + && typeof this.collection.updateByKeys === 'function') { + const watched = this.collection.applyUpdateWatchers(this.record, updates); + this.record = watched.record; + await this.collection.updateByKeys(keys, watched.updates); + return; + } + + if (this.patchRecord && typeof this.patchRecord.go === 'function') { + await this.patchRecord.go(); + return; + } + + throw new ValidationError(`No persistence strategy available for ${this.entityName}`); } getUpdates() { diff --git a/packages/spacecat-shared-data-access/src/util/postgrest.utils.js b/packages/spacecat-shared-data-access/src/util/postgrest.utils.js new file mode 100644 index 000000000..11da39285 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/util/postgrest.utils.js @@ -0,0 +1,127 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import pluralize from 'pluralize'; + +const DEFAULT_PAGE_SIZE = 1000; + +const ENTITY_TABLE_OVERRIDES = { + LatestAudit: 'audits', +}; + +const camelToSnake = (value) => value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); + +const snakeToCamel = (value) => value.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); + +const entityToTableName = (entityName) => { + const override = ENTITY_TABLE_OVERRIDES[entityName]; + if (override) { + return override; + } + return camelToSnake(pluralize.plural(entityName)); +}; + +const encodeCursor = (offset) => Buffer.from(JSON.stringify({ offset }), 'utf-8').toString('base64'); + +const decodeCursor = (cursor) => { + if (!cursor) { + return 0; + } + + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')); + return Number.isInteger(decoded.offset) && decoded.offset >= 0 ? decoded.offset : 0; + } catch (e) { + return 0; + } +}; + +const createFieldMaps = (schema) => { + const toDbMap = {}; + const toModelMap = {}; + const attributes = schema.getAttributes(); + const idName = typeof schema.getIdName === 'function' ? schema.getIdName() : undefined; + Object.keys(attributes).forEach((modelField) => { + const attribute = attributes[modelField] || {}; + const dbField = attribute.postgrestField + || (modelField === idName && modelField !== 'id' ? 'id' : camelToSnake(modelField)); + toDbMap[modelField] = dbField; + toModelMap[dbField] = modelField; + }); + + if (idName && idName !== 'id') { + toDbMap[idName] = 'id'; + toModelMap.id = idName; + } + + return { toDbMap, toModelMap }; +}; + +const toDbField = (field, map) => map[field] || camelToSnake(field); + +const toModelField = (field, map) => map[field] || snakeToCamel(field); + +const toDbRecord = (record, toDbMap) => Object.entries(record).reduce((acc, [key, value]) => { + acc[toDbField(key, toDbMap)] = value; + return acc; +}, {}); + +const fromDbRecord = (record, toModelMap) => Object.entries(record).reduce((acc, [key, value]) => { + acc[toModelField(key, toModelMap)] = value; + return acc; +}, {}); + +const applyWhere = (query, whereFn, toDbMap) => { + if (typeof whereFn !== 'function') { + return query; + } + + const attrs = new Proxy({}, { + get: (_, prop) => toDbField(String(prop), toDbMap), + }); + + const op = { + eq: (field, value) => ({ type: 'eq', field, value }), + contains: (field, value) => ({ type: 'contains', field, value }), + }; + + const expression = whereFn(attrs, op); + if (!expression || typeof expression !== 'object') { + return query; + } + + if (expression.type === 'eq') { + return query.eq(expression.field, expression.value); + } + + if (expression.type === 'contains') { + const value = Array.isArray(expression.value) ? expression.value : [expression.value]; + return query.contains(expression.field, value); + } + + return query; +}; + +export { + DEFAULT_PAGE_SIZE, + applyWhere, + camelToSnake, + createFieldMaps, + decodeCursor, + encodeCursor, + entityToTableName, + fromDbRecord, + snakeToCamel, + toDbField, + toDbRecord, + toModelField, +}; diff --git a/packages/spacecat-shared-data-access/test/unit/models/audit/audit.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/audit/audit.collection.test.js index 7fea48e8c..9df88dd1a 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/audit/audit.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/audit/audit.collection.test.js @@ -61,7 +61,7 @@ describe('AuditCollection', () => { }); describe('onCreate', () => { - it('creates a LatestAudit entity', async () => { + it('does not create a LatestAudit entity in v3', async () => { const collection = { create: stub().resolves(), }; @@ -70,11 +70,10 @@ describe('AuditCollection', () => { // eslint-disable-next-line no-underscore-dangle await instance._onCreate(model); - expect(collection.create).to.have.been.calledOnce; - expect(collection.create).to.have.been.calledWithExactly(model.toJSON()); + expect(collection.create).to.not.have.been.called; }); - it('creates a LatestAudit entity for each site and auditType', async () => { + it('does not create LatestAudit entities on createMany in v3', async () => { const collection = { createMany: stub().resolves(), }; @@ -85,7 +84,7 @@ describe('AuditCollection', () => { createdItems: [model, model, model], }); - expect(collection.createMany).to.have.been.calledOnce; + expect(collection.createMany).to.not.have.been.called; }); }); }); 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 b12046be9..3cde2c823 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 @@ -34,24 +34,24 @@ const MockCollection = class MockEntityCollection extends BaseCollection { static COLLECTION_NAME = 'MockEntityCollection'; }; -const createSchema = (service, indexes) => new Schema( +const createSchema = (service, indexes, attributes = { + someKey: { type: 'string' }, + someOtherKey: { type: 'number' }, +}) => new Schema( MockModel, MockCollection, { serviceName: 'service', schemaVersion: 1, - attributes: { - someKey: { type: 'string' }, - someOtherKey: { type: 'number' }, - }, + attributes, indexes, references: [], options: { allowRemove: true, allowUpdates: true }, }, ); -const createInstance = (service, registry, indexes, log) => { - const schema = createSchema(service, indexes); +const createInstance = (service, registry, indexes, log, attributes) => { + const schema = createSchema(service, indexes, attributes); return new BaseCollection( service, registry, @@ -1007,6 +1007,43 @@ describe('BaseCollection', () => { { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, ]); }); + + it('removes entities successfully via PostgREST service when entity proxy is unavailable', async () => { + const inStub = stub().resolves({ error: null }); + const deleteStub = stub().returns({ in: inStub }); + const fromStub = stub().returns({ delete: deleteStub }); + const postgrestService = { from: fromStub }; + + const instance = createInstance( + postgrestService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + const mockIds = ['ef39921f-9a02-41db-b491-02c98987d956', 'ef39921f-9a02-41db-b491-02c98987d957']; + await instance.removeByIds(mockIds); + + expect(fromStub).to.have.been.calledOnceWithExactly(instance.tableName); + expect(inStub).to.have.been.calledOnceWithExactly('id', mockIds); + }); + + it('throws when PostgREST removeByIds operation returns an error', async () => { + const inStub = stub().resolves({ error: new Error('delete failed') }); + const deleteStub = stub().returns({ in: inStub }); + const fromStub = stub().returns({ delete: deleteStub }); + const postgrestService = { from: fromStub }; + + const instance = createInstance( + postgrestService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + await expect(instance.removeByIds(['ef39921f-9a02-41db-b491-02c98987d956'])) + .to.be.rejectedWith(DataAccessError, 'Failed to remove by IDs'); + }); }); describe('batchGetByKeys', () => { @@ -1459,5 +1496,549 @@ describe('BaseCollection', () => { await expect(baseCollectionInstance.removeByIndexKeys(keys)) .to.be.rejectedWith(DataAccessError, 'key must be a non-empty object'); }); + + it('should remove by keys via PostgREST service when entity proxy is unavailable', async () => { + const query = { + error: null, + eq: stub().callsFake(() => query), + }; + const fromStub = stub().returns({ + delete: stub().returns(query), + }); + const postgrestService = { from: fromStub }; + const instance = createInstance( + postgrestService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + const keys = [{ someKey: 'test-value', someOtherKey: 42 }]; + + await instance.removeByIndexKeys(keys); + + expect(fromStub).to.have.been.calledOnceWithExactly(instance.tableName); + expect(query.eq).to.have.been.calledWith('some_key', 'test-value'); + expect(query.eq).to.have.been.calledWith('some_other_key', 42); + expect(mockLogger.info).to.have.been.calledWith(`Removed ${keys.length} items for [mockEntityModel]`); + }); + + it('should throw when PostgREST removeByIndexKeys returns an error', async () => { + const query = { + error: new Error('delete failed'), + eq: stub().callsFake(() => query), + }; + const fromStub = stub().returns({ + delete: stub().returns(query), + }); + const postgrestService = { from: fromStub }; + const instance = createInstance( + postgrestService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + await expect(instance.removeByIndexKeys([{ someKey: 'test-value' }])) + .to.be.rejectedWith(DataAccessError, 'Failed to remove by index keys'); + }); + }); + + describe('postgrest mode', () => { + const richAttributes = { + someKey: { type: 'string', required: true }, + someOtherKey: { type: 'number', default: 7 }, + isActive: { type: 'boolean', default: false }, + tags: { type: 'list', default: () => ['default'] }, + metadata: { type: 'map', default: () => ({ source: 'unit' }) }, + mode: { type: ['A', 'B'], default: 'A' }, + computed: { type: 'string', set: (_, record) => `${record.someKey}-computed` }, + watchedValue: { + type: 'number', + watch: ['someOtherKey'], + set: (value) => (value || 0) + 1, + }, + watchedAll: { + type: 'string', + watch: '*', + set: (_, record) => `${record.someKey}-${record.someOtherKey}`, + }, + validateFalse: { type: 'string', validate: () => false }, + validateThrows: { type: 'string', validate: () => { throw new Error('validation throws'); } }, + }; + + const richIndexes = { + primary: { + index: 'primary', + pk: { facets: ['someKey'] }, + sk: { facets: ['someOtherKey'] }, + }, + bySomeKey: { + index: 'bySomeKey', + pk: { facets: ['someKey'] }, + sk: { facets: ['someOtherKey'] }, + }, + all: { index: 'all', indexType: 'all' }, + }; + + const createPostgrestQuery = (responses) => { + const queue = Array.isArray(responses) ? [...responses] : [responses]; + const query = { + select: stub().returnsThis(), + order: stub().returnsThis(), + eq: stub().returnsThis(), + gte: stub().returnsThis(), + lte: stub().returnsThis(), + range: stub().returnsThis(), + contains: stub().returnsThis(), + then: (onFulfilled, onRejected) => Promise + .resolve(queue.shift() || { data: [], error: null }) + .then(onFulfilled, onRejected), + }; + return query; + }; + + it('queries PostgREST with filters and returns cursor payload', async () => { + const query = createPostgrestQuery({ + data: [ + { some_key: 'a', some_other_key: 1, computed: 'a-computed' }, + { some_key: 'a', some_other_key: 2, computed: 'a-computed' }, + ], + error: null, + }); + const fromStub = stub().returns({ + select: stub().returns(query), + }); + + const instance = createInstance( + { from: fromStub }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + const result = await instance.allByIndexKeys( + { someKey: 'a', someOtherKey: 1 }, + { + attributes: ['someKey'], + between: { attribute: 'someOtherKey', start: 1, end: 2 }, + where: (attr, op) => op.eq(attr.someKey, 'a'), + order: 'asc', + limit: 2, + returnCursor: true, + fetchAllPages: false, + }, + ); + + expect(result.data).to.have.length(2); + expect(result.data[0].record.someKey).to.equal('a'); + expect(result.cursor).to.be.a('string'); + expect(query.order).to.have.been.calledOnce; + expect(query.range).to.have.been.calledOnceWithExactly(0, 1); + }); + + it('fetches all pages in PostgREST mode', async () => { + const query = createPostgrestQuery([ + { + data: [ + { some_key: 'a', some_other_key: 1 }, + { some_key: 'a', some_other_key: 2 }, + ], + error: null, + }, + { + data: [ + { some_key: 'a', some_other_key: 3 }, + { some_key: 'a', some_other_key: 4 }, + ], + error: null, + }, + { data: [], error: null }, + ]); + const fromStub = stub().returns({ + select: stub().returns(query), + }); + + const instance = createInstance( + { from: fromStub }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + const result = await instance.allByIndexKeys( + { someKey: 'a' }, + { fetchAllPages: true, limit: 2, returnCursor: true }, + ); + + expect(result.data).to.have.length(4); + expect(result.cursor).to.equal(null); + expect(query.range).to.have.callCount(3); + }); + + it('uses PostgREST path for findById, existsById and batchGetByKeys', async () => { + const query = createPostgrestQuery([ + { data: [{ some_key: 'id-1', some_other_key: 1 }], error: null }, + { data: [{ some_key: 'id-1', some_other_key: 1 }], error: null }, + { data: [{ some_key: 'id-1', some_other_key: 1 }], error: null }, + { data: [], error: null }, + ]); + const fromStub = stub().returns({ + select: stub().returns(query), + }); + + const instance = createInstance( + { from: fromStub }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + const found = await instance.findById('ef39921f-9a02-41db-b491-02c98987d956'); + expect(found).to.not.be.null; + + const exists = await instance.existsById('ef39921f-9a02-41db-b491-02c98987d956'); + expect(exists).to.equal(true); + + const batch = await instance.batchGetByKeys([ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ]); + expect(batch.data).to.have.length(1); + expect(batch.unprocessed).to.deep.equal([]); + }); + + it('creates with insert and upsert in PostgREST mode', async () => { + const maybeSingle = stub() + .onFirstCall() + .resolves({ data: { some_key: 'a', some_other_key: 7 }, error: null }) + .onSecondCall() + .resolves({ data: { some_key: 'b', some_other_key: 9 }, error: null }); + const select = stub().returns({ maybeSingle }); + const insert = stub().returns({ select }); + const upsert = stub().returns({ select }); + const fromStub = stub().returns({ + insert, + upsert, + }); + const instance = createInstance( + { from: fromStub }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + const created = await instance.create({ someKey: 'a' }); + expect(created.record.someOtherKey).to.equal(7); + expect(insert).to.have.been.calledOnce; + + const upserted = await instance.create( + { someKey: 'b', someOtherKey: 9 }, + { upsert: true }, + ); + expect(upserted.record.someKey).to.equal('b'); + expect(upsert).to.have.been.calledOnce; + }); + + it('validates create payload errors for PostgREST mode', async () => { + const fromStub = stub().returns({ + insert: stub().returns({ + select: stub().returns({ + maybeSingle: stub().resolves({ data: {}, error: null }), + }), + }), + }); + const instance = createInstance( + { from: fromStub }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + const invalidCases = [ + {}, + { someKey: 1 }, + { someKey: 'x', someOtherKey: 'bad' }, + { someKey: 'x', isActive: 'bad' }, + { someKey: 'x', tags: 'bad' }, + { someKey: 'x', metadata: 'bad' }, + { someKey: 'x', mode: 'C' }, + { someKey: 'x', validateFalse: 'v' }, + { someKey: 'x', validateThrows: 'v' }, + ]; + + await Promise.all(invalidCases.map(async (item) => { + await expect(instance.create(item)).to.be.rejectedWith(DataAccessError); + })); + }); + + it('creates many in PostgREST mode with validation split', async () => { + const select = stub().resolves({ error: null }); + const insert = stub().returns({ select }); + const fromStub = stub().returns({ insert }); + const instance = createInstance( + { from: fromStub }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + const result = await instance.createMany([ + { someKey: 'valid-1' }, + { someKey: 123 }, + ]); + + expect(result.createdItems).to.have.length(1); + expect(result.errorItems).to.have.length(1); + expect(select).to.have.been.calledOnce; + }); + + it('updates by keys and saveMany in PostgREST mode', async () => { + const maybeSingle = stub().resolves({ data: { some_key: 'a' }, error: null }); + const updateQuery = { + eq: stub().returnsThis(), + select: stub().returns({ maybeSingle }), + }; + const update = stub().returns(updateQuery); + const fromStub = stub().returns({ + update, + }); + const instance = createInstance( + { from: fromStub }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + await instance.updateByKeys({ someKey: 'a' }, { someOtherKey: 3 }); + expect(update).to.have.been.calledOnce; + expect(updateQuery.eq).to.have.been.calledWith('some_key', 'a'); + + const withComposite = { + record: { someKey: 'a', someOtherKey: 3 }, + generateCompositeKeys: () => ({ someKey: 'a', someOtherKey: 3 }), + }; + const withoutComposite = { + record: { someKey: 'b', someOtherKey: 4 }, + getId: () => 'b', + }; + await instance._saveMany([withComposite, withoutComposite]); + expect(update.callCount).to.equal(3); + }); + + it('covers PostgREST query edge paths and errors', async () => { + const goodQuery = createPostgrestQuery({ + data: [{ some_key: 'a', some_other_key: 1 }], + error: null, + }); + const errorQuery = createPostgrestQuery({ data: null, error: new Error('query failed') }); + + const fromStub = stub() + .onFirstCall().returns({ select: stub().returns(goodQuery) }) + .onSecondCall() + .returns({ select: stub().returns(errorQuery) }) + .onThirdCall() + .returns({ select: stub().returns(goodQuery) }); + + const instance = createInstance( + { from: fromStub }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + await expect( + instance.allByIndexKeys({ someKey: 'a' }, { index: 'missing-index', fetchAllPages: false }), + ).to.be.rejectedWith(DataAccessError, 'query proxy [missing-index] not found'); + + await expect( + instance.allByIndexKeys({ someKey: 'a' }, { fetchAllPages: false }), + ).to.eventually.have.length(1); + + await expect( + instance.allByIndexKeys({ someKey: 'a' }, { fetchAllPages: false }), + ).to.be.rejectedWith(DataAccessError, 'Failed to query'); + }); + + it('handles create and createMany PostgREST failure paths', async () => { + const instance = createInstance( + { + from: stub().returns({ + insert: stub().returns({ + select: stub().returns({ + maybeSingle: stub().resolves({ data: null, error: new Error('create failed') }), + }), + }), + }), + }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + await expect(instance.create({ someKey: 'a' })) + .to.be.rejectedWith(DataAccessError, 'Failed to create'); + + const throwingAttributes = { + ...richAttributes, + throwingSet: { + type: 'string', + set: () => { throw new Error('setter failed'); }, + }, + }; + + const instanceWithThrowingSetter = createInstance( + { + from: stub().returns({ + insert: stub().returns({ + select: stub().resolves({ error: null }), + }), + }), + }, + mockEntityRegistry, + richIndexes, + mockLogger, + throwingAttributes, + ); + + await expect(instanceWithThrowingSetter.createMany([{ someKey: 'a', throwingSet: 'x' }])) + .to.be.rejectedWith(DataAccessError, 'Failed to create many'); + + const instanceWithInsertError = createInstance( + { + from: stub().returns({ + insert: stub().returns({ + select: stub().resolves({ error: new Error('insert failed') }), + }), + }), + }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + await expect(instanceWithInsertError.createMany([{ someKey: 'a' }])) + .to.be.rejectedWith(DataAccessError, 'Failed to create many'); + }); + + it('covers createMany parent association branches', async () => { + const select = stub().resolves({ error: null }); + const insert = stub().returns({ select }); + const instance = createInstance( + { from: stub().returns({ insert }) }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + const validParent = { + entityName: 'mockEntityModel', + record: { mockEntityModelId: 'valid-parent-id' }, + schema: { getModelName: () => 'MockEntityModel' }, + }; + const invalidParent = { + entityName: 'mockEntityModel', + record: { mockEntityModelId: 'different-parent-id' }, + schema: { getModelName: () => 'MockEntityModel' }, + }; + + const payload = [{ someKey: 'a', mockEntityModelId: 'valid-parent-id' }]; + const valid = await instance.createMany(payload, validParent); + const invalid = await instance.createMany(payload, invalidParent); + + expect(valid.createdItems[0]._accessorCache.getMockEntityModel).to.equal(validParent); + expect(invalid.createdItems[0]._accessorCache.getMockEntityModel).to.not.equal(invalidParent); + }); + + it('covers updateByKeys entity path and error branches', async () => { + const patch = { + set: stub().returnsThis(), + go: stub().resolves(), + }; + const instanceWithEntity = createInstance( + { + entities: { + mockEntityModel: { + patch: stub().returns(patch), + }, + }, + }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + await instanceWithEntity.updateByKeys({ someKey: 'a' }, { someOtherKey: 1 }); + expect(patch.set).to.have.been.calledOnceWithExactly({ someOtherKey: 1 }); + expect(patch.go).to.have.been.calledOnce; + + await expect(instanceWithEntity.updateByKeys(null, { someOtherKey: 1 })) + .to.be.rejectedWith(DataAccessError, 'keys and updates are required'); + + const instanceWithError = createInstance( + { + from: stub().returns({ + update: stub().returns({ + eq: stub().returnsThis(), + select: stub().returns({ + maybeSingle: stub().resolves({ error: new Error('update failed') }), + }), + }), + }), + }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + await expect(instanceWithError.updateByKeys({ someKey: 'a' }, { someOtherKey: 2 })) + .to.be.rejectedWith(DataAccessError, 'Failed to update entity'); + }); + + it('covers applyUpdateWatchers empty-update branch', () => { + const instance = createInstance( + { from: stub() }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + const result = instance.applyUpdateWatchers({ someKey: 'a' }, {}); + expect(result).to.deep.equal({ record: { someKey: 'a' }, updates: {} }); + }); + + it('covers required-field validation branch with non-empty payload', async () => { + const instance = createInstance( + { + from: stub().returns({ + insert: stub().returns({ + select: stub().returns({ + maybeSingle: stub().resolves({ data: {}, error: null }), + }), + }), + }), + }, + mockEntityRegistry, + richIndexes, + mockLogger, + richAttributes, + ); + + await expect(instance.create({ someOtherKey: 1 })) + .to.be.rejectedWith(DataAccessError, 'Failed to create'); + }); }); }); 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 e8435a419..cebbbf296 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 @@ -318,6 +318,23 @@ describe('BaseModel', () => { /* eslint-disable no-underscore-dangle */ await expect(baseModelInstance.remove()).to.be.rejectedWith('The entity Opportunity does not allow removal'); expect(mockElectroService.entities.opportunity.remove.notCalled).to.be.true; }); + + it('throws when no remove strategy is available', async () => { + baseModelInstance.collection = null; + delete mockElectroService.entities.opportunity.remove; + + await expect(baseModelInstance._remove()) + .to.be.rejectedWith('Failed to remove entity opportunity with ID 12345'); + }); + + it('uses collection removeByIndexKeys strategy when available', async () => { + const removeByIndexKeys = stub().resolves(); + baseModelInstance.collection = { removeByIndexKeys }; + baseModelInstance.postgrestService.entities.opportunity.remove = undefined; + + await expect(baseModelInstance._remove()).to.eventually.equal(baseModelInstance); + expect(removeByIndexKeys).to.have.been.calledOnce; + }); }); describe('save', () => { @@ -361,4 +378,13 @@ describe('BaseModel', () => { /* eslint-disable no-underscore-dangle */ }); }); }); + + describe('toJSON', () => { + it('returns only schema-defined attributes that exist on record', () => { + baseModelInstance.record.notInSchema = 'ignore-me'; + const result = baseModelInstance.toJSON(); + expect(result.opportunityId).to.equal(mockRecord.opportunityId); + expect(result).to.not.have.property('notInSchema'); + }); + }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/models/base/schema.builder.test.js b/packages/spacecat-shared-data-access/test/unit/models/base/schema.builder.test.js index a85731bec..e511cd2a1 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/base/schema.builder.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/base/schema.builder.test.js @@ -87,6 +87,7 @@ describe('SchemaBuilder', () => { mockModelId: { default: instance.attributes.mockModelId.default, type: 'string', + postgrestField: 'id', required: true, readOnly: true, validate: instance.attributes.mockModelId.validate, @@ -451,6 +452,7 @@ describe('SchemaBuilder', () => { attributes: { mockModelId: { type: 'string', + postgrestField: 'id', required: true, readOnly: true, validate: instance.attributes.mockModelId.validate, diff --git a/packages/spacecat-shared-data-access/test/unit/models/key-event/key-event.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/key-event/key-event.collection.test.js index b3b98bdcb..5019943ca 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/key-event/key-event.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/key-event/key-event.collection.test.js @@ -58,4 +58,17 @@ describe('KeyEventCollection', () => { expect(model).to.be.an('object'); }); }); + + describe('deprecated behavior', () => { + it('throws deprecation errors on all collection operations', async () => { + await expect(instance.all()).to.be.rejectedWith('KeyEvent is deprecated in data-access v3'); + await expect(instance.allByIndexKeys({})).to.be.rejectedWith('KeyEvent is deprecated in data-access v3'); + await expect(instance.findById('id')).to.be.rejectedWith('KeyEvent is deprecated in data-access v3'); + await expect(instance.findByIndexKeys({})).to.be.rejectedWith('KeyEvent is deprecated in data-access v3'); + await expect(instance.create({})).to.be.rejectedWith('KeyEvent is deprecated in data-access v3'); + await expect(instance.createMany([{}])).to.be.rejectedWith('KeyEvent is deprecated in data-access v3'); + await expect(instance.removeByIds(['id'])).to.be.rejectedWith('KeyEvent is deprecated in data-access v3'); + await expect(instance.removeByIndexKeys([{ keyEventId: 'id' }])).to.be.rejectedWith('KeyEvent is deprecated in data-access v3'); + }); + }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/models/latest-audit/latest-audit.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/latest-audit/latest-audit.collection.test.js index 9f8b0dbc6..4ce7d01e7 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/latest-audit/latest-audit.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/latest-audit/latest-audit.collection.test.js @@ -61,11 +61,127 @@ describe('LatestAuditCollection', () => { }); describe('create', () => { - it('creates a new latest audit', async () => { - const result = await instance.create(mockRecord); + it('throws because latest audit is derived in v3', async () => { + await expect(instance.create(mockRecord)) + .to.be.rejectedWith('LatestAudit is derived from Audit in v3 and cannot be created directly'); + }); + }); + + describe('createMany', () => { + it('throws because latest audit is derived in v3', async () => { + await expect(instance.createMany([mockRecord])) + .to.be.rejectedWith('LatestAudit is derived from Audit in v3 and cannot be created directly'); + }); + }); + + describe('all/find aliases', () => { + it('delegates all to allByIndexKeys', async () => { + const result = [mockRecord]; + instance.allByIndexKeys = stub().resolves(result); + + const response = await instance.all({ siteId: 'site-1' }, { limit: 5 }); + expect(response).to.equal(result); + expect(instance.allByIndexKeys).to.have.been.calledOnceWithExactly({ siteId: 'site-1' }, { limit: 5 }); + }); + + it('delegates findByAll to findByIndexKeys', async () => { + instance.findByIndexKeys = stub().resolves(mockRecord); + + const response = await instance.findByAll({ siteId: 'site-1' }, { order: 'desc' }); + expect(response).to.equal(mockRecord); + expect(instance.findByIndexKeys).to.have.been.calledOnceWithExactly({ siteId: 'site-1' }, { order: 'desc' }); + }); + }); + + describe('findByIndexKeys', () => { + it('delegates directly to audit collection for exact siteId+auditType', async () => { + const auditCollection = { + findByIndexKeys: stub().resolves(mockRecord), + }; + mockEntityRegistry.getCollection.withArgs('AuditCollection').returns(auditCollection); + + const result = await instance.findByIndexKeys({ siteId: 'site-1', auditType: 'lhs-mobile' }); + expect(result).to.equal(mockRecord); + expect(auditCollection.findByIndexKeys).to.have.been.calledOnceWithExactly( + { siteId: 'site-1', auditType: 'lhs-mobile' }, + { order: 'desc' }, + ); + }); + + it('returns null when no audits are found for grouped lookup', async () => { + const auditCollection = { + allByIndexKeys: stub().resolves([]), + }; + mockEntityRegistry.getCollection.withArgs('AuditCollection').returns(auditCollection); + + const result = await instance.findByIndexKeys({ siteId: 'site-1' }); + expect(result).to.be.null; + }); + + it('groups and returns latest audit when querying across sites', async () => { + const audits = [ + { getAuditedAt: () => '2025-01-01T00:00:00.000Z', record: { siteId: 'site-1', auditType: 'lhs-mobile' } }, + { getAuditedAt: () => '2025-01-02T00:00:00.000Z', record: { siteId: 'site-2', auditType: 'lhs-mobile' } }, + ]; + const auditCollection = { + allByIndexKeys: stub().resolves(audits), + }; + mockEntityRegistry.getCollection.withArgs('AuditCollection').returns(auditCollection); + + const result = await instance.findByIndexKeys({ auditType: 'lhs-mobile' }); + expect(result).to.equal(audits[0]); + }); + }); + + describe('allByIndexKeys', () => { + it('returns empty array when no audits are found', async () => { + const auditCollection = { + allByIndexKeys: stub().resolves([]), + }; + mockEntityRegistry.getCollection.withArgs('AuditCollection').returns(auditCollection); + + const result = await instance.allByIndexKeys({ siteId: 'site-1' }); + expect(result).to.deep.equal([]); + }); + + it('returns empty cursor payload when no audits are found and returnCursor is true', async () => { + const auditCollection = { + allByIndexKeys: stub().resolves([]), + }; + mockEntityRegistry.getCollection.withArgs('AuditCollection').returns(auditCollection); + + const result = await instance.allByIndexKeys({ siteId: 'site-1' }, { returnCursor: true }); + expect(result).to.deep.equal({ data: [], cursor: null }); + }); + + it('groups by site when filtering only by auditType', async () => { + const audits = [ + { getAuditedAt: () => '2025-02-01T00:00:00.000Z', record: { siteId: 's1', auditType: 'lhs-mobile' } }, + { getAuditedAt: () => '2025-02-02T00:00:00.000Z', record: { siteId: 's1', auditType: 'lhs-mobile' } }, + { getAuditedAt: () => '2025-01-01T00:00:00.000Z', record: { siteId: 's2', auditType: 'lhs-mobile' } }, + ]; + const auditCollection = { + allByIndexKeys: stub().resolves(audits), + }; + mockEntityRegistry.getCollection.withArgs('AuditCollection').returns(auditCollection); + + const result = await instance.allByIndexKeys({ auditType: 'lhs-mobile' }, { limit: 1, returnCursor: true }); + expect(result.data).to.have.length(1); + expect(result.cursor).to.equal(null); + }); - expect(result).to.be.an('object'); - expect(result.record.latestAuditId).to.equal(mockRecord.latestAuditId); + it('groups by auditType when filtering by site only', async () => { + const audits = [ + { getAuditedAt: () => '2025-02-01T00:00:00.000Z', record: { siteId: 's1', auditType: 'lhs-mobile' } }, + { getAuditedAt: () => '2025-02-03T00:00:00.000Z', record: { siteId: 's1', auditType: 'seo' } }, + ]; + const auditCollection = { + allByIndexKeys: stub().resolves(audits), + }; + mockEntityRegistry.getCollection.withArgs('AuditCollection').returns(auditCollection); + + const result = await instance.allByIndexKeys({ siteId: 's1' }); + expect(result).to.have.length(2); }); }); @@ -88,7 +204,7 @@ describe('LatestAuditCollection', () => { const siteId = '78fec9c7-2141-4600-b7b1-ea5c78752b91'; const auditType = 'lhs-mobile'; - instance.findByIndexKeys = stub().returns({ go: stub().resolves({ data: [mockRecord] }) }); + instance.findByIndexKeys = stub().resolves(mockRecord); const audit = await instance.findById(siteId, auditType); diff --git a/packages/spacecat-shared-data-access/test/unit/service/index.test.js b/packages/spacecat-shared-data-access/test/unit/service/index.test.js new file mode 100644 index 000000000..0e08573ea --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/service/index.test.js @@ -0,0 +1,63 @@ +/* + * Copyright 2026 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 { createDataAccess } from '../../../src/service/index.js'; + +describe('service/index', () => { + it('uses provided PostgREST client and does not require postgrestUrl', () => { + const client = {}; + const dataAccess = createDataAccess({}, console, client); + + expect(dataAccess).to.be.an('object'); + }); + + it('throws when postgrestUrl is missing and no client is provided', () => { + expect(() => createDataAccess({}, console)) + .to.throw('postgrestUrl is required to create data access'); + }); + + it('creates data access with PostgREST config and no S3 bucket', () => { + const dataAccess = createDataAccess({ + postgrestUrl: 'http://localhost:3000', + postgrestSchema: 'public', + postgrestApiKey: 'api-key', + postgrestHeaders: { + 'x-test-header': 'value', + }, + }, console); + + expect(dataAccess).to.be.an('object'); + }); + + it('creates data access with optional S3 config', () => { + const dataAccess = createDataAccess({ + postgrestUrl: 'http://localhost:3000', + s3Bucket: 'test-bucket', + region: 'us-east-1', + }, console, {}); + + expect(dataAccess).to.be.an('object'); + }); + + it('creates data access with S3 bucket and default region options', () => { + const dataAccess = createDataAccess({ + postgrestUrl: 'http://localhost:3000', + s3Bucket: 'test-bucket', + }, console, {}); + + expect(dataAccess).to.be.an('object'); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/util/patcher.test.js b/packages/spacecat-shared-data-access/test/unit/util/patcher.test.js index 8aec1e11d..d8e98618d 100755 --- a/packages/spacecat-shared-data-access/test/unit/util/patcher.test.js +++ b/packages/spacecat-shared-data-access/test/unit/util/patcher.test.js @@ -244,4 +244,39 @@ describe('Patcher', () => { expect(() => patcher.patchValue('nickNames', ['name1', 123])) .to.throw('Validation failed in mockEntityModel: nickNames must contain items of type string'); }); + + it('saves using collection update strategy when available', async () => { + const applyUpdateWatchers = sinon.stub().returns({ + record: { ...mockRecord, name: 'CollectionUpdated' }, + updates: { name: 'CollectionUpdated', updatedAt: '2026-01-01T00:00:00.000Z' }, + }); + const updateByKeys = sinon.stub().resolves(); + patcher.collection = { applyUpdateWatchers, updateByKeys }; + + patcher.patchValue('name', 'UpdatedName'); + await patcher.save(); + + expect(updateByKeys.calledOnce).to.be.true; + expect(mockEntity.patch().go.notCalled).to.be.true; + }); + + it('throws when no persistence strategy is available', async () => { + patcher.patchValue('name', 'UpdatedName'); + patcher.collection = undefined; + patcher.patchRecord = undefined; + + await expect(patcher.save()) + .to.be.rejectedWith('No persistence strategy available for mockEntityModel'); + }); + + it('uses primary index keys when building legacy patch composite', async () => { + patcher.schema.indexes.primary = { + pk: { facets: ['testEntityId'] }, + sk: { facets: ['name'] }, + }; + patcher.patchValue('name', 'UpdatedName'); + + await patcher.save(); + expect(mockEntity.patch).to.have.been.called; + }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/util/postgrest.utils.test.js b/packages/spacecat-shared-data-access/test/unit/util/postgrest.utils.test.js new file mode 100644 index 000000000..27682f4da --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/util/postgrest.utils.test.js @@ -0,0 +1,191 @@ +/* + * Copyright 2026 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 sinon from 'sinon'; + +import { + applyWhere, + camelToSnake, + createFieldMaps, + decodeCursor, + encodeCursor, + entityToTableName, + fromDbRecord, + snakeToCamel, + toDbField, + toDbRecord, + toModelField, +} from '../../../src/util/postgrest.utils.js'; +import SiteSchema from '../../../src/models/site/site.schema.js'; + +describe('postgrest utils', () => { + it('transforms camel/snake case values', () => { + expect(camelToSnake('auditType')).to.equal('audit_type'); + expect(snakeToCamel('audit_type')).to.equal('auditType'); + }); + + it('resolves table names with entity overrides', () => { + expect(entityToTableName('LatestAudit')).to.equal('audits'); + expect(entityToTableName('ScrapeJob')).to.equal('scrape_jobs'); + }); + + it('encodes and decodes cursors', () => { + const encoded = encodeCursor(42); + const invalidOffset = Buffer.from(JSON.stringify({ offset: 'not-int' }), 'utf-8').toString('base64'); + expect(decodeCursor(encoded)).to.equal(42); + expect(decodeCursor('')).to.equal(0); + expect(decodeCursor('bad-cursor')).to.equal(0); + expect(decodeCursor(invalidOffset)).to.equal(0); + }); + + it('creates field maps from schema attributes', () => { + const schema = { + getAttributes: () => ({ + siteId: {}, + auditType: {}, + }), + }; + + const maps = createFieldMaps(schema); + expect(maps.toDbMap).to.deep.equal({ + siteId: 'site_id', + auditType: 'audit_type', + }); + expect(maps.toModelMap).to.deep.equal({ + site_id: 'siteId', + audit_type: 'auditType', + }); + }); + + it('maps model id field to DB id when schema idName is defined', () => { + const schema = { + getIdName: () => 'siteId', + getAttributes: () => ({ + siteId: {}, + baseURL: {}, + }), + }; + + const maps = createFieldMaps(schema); + expect(maps.toDbMap).to.deep.equal({ + siteId: 'id', + baseURL: 'base_url', + }); + expect(maps.toModelMap).to.deep.equal({ + id: 'siteId', + base_url: 'baseURL', + }); + }); + + it('maps idName to id even when id field is not in schema attributes', () => { + const schema = { + getIdName: () => 'siteId', + getAttributes: () => ({ + baseURL: {}, + }), + }; + + const maps = createFieldMaps(schema); + expect(maps.toDbMap).to.deep.equal({ + baseURL: 'base_url', + siteId: 'id', + }); + expect(maps.toModelMap).to.deep.equal({ + base_url: 'baseURL', + id: 'siteId', + }); + }); + + it('uses explicit postgrestField from schema attribute definition', () => { + const schema = { + getIdName: () => 'siteId', + getAttributes: () => ({ + siteId: {}, + baseURL: { postgrestField: 'base_url_override' }, + }), + }; + + const maps = createFieldMaps(schema); + expect(maps.toDbMap).to.deep.equal({ + siteId: 'id', + baseURL: 'base_url_override', + }); + expect(maps.toModelMap).to.deep.equal({ + id: 'siteId', + base_url_override: 'baseURL', + }); + }); + + it('maps real Site schema fields base_url <-> baseURL', () => { + const maps = createFieldMaps(SiteSchema); + expect(maps.toDbMap.siteId).to.equal('id'); + expect(maps.toDbMap.baseURL).to.equal('base_url'); + expect(maps.toModelMap.base_url).to.equal('baseURL'); + + expect(fromDbRecord({ id: 'site-1', base_url: 'https://example.com' }, maps.toModelMap)) + .to.deep.equal({ siteId: 'site-1', baseURL: 'https://example.com' }); + }); + + it('maps individual db/model fields and whole records', () => { + const toDbMap = { + siteId: 'site_id', + auditType: 'audit_type', + }; + const toModelMap = { + site_id: 'siteId', + audit_type: 'auditType', + }; + + expect(toDbField('siteId', toDbMap)).to.equal('site_id'); + expect(toDbField('createdAt', toDbMap)).to.equal('created_at'); + expect(toModelField('site_id', toModelMap)).to.equal('siteId'); + expect(toModelField('created_at', toModelMap)).to.equal('createdAt'); + expect(toDbRecord({ siteId: 's1', auditType: 'lhs-mobile' }, toDbMap)).to.deep.equal({ + site_id: 's1', + audit_type: 'lhs-mobile', + }); + expect(fromDbRecord({ site_id: 's1', audit_type: 'lhs-mobile' }, toModelMap)).to.deep.equal({ + siteId: 's1', + auditType: 'lhs-mobile', + }); + }); + + it('returns query unchanged when where clause is not usable', () => { + const query = {}; + expect(applyWhere(query, null, {})).to.equal(query); + expect(applyWhere(query, () => null, {})).to.equal(query); + expect(applyWhere(query, () => 'invalid', {})).to.equal(query); + expect(applyWhere(query, () => ({ type: 'unknown' }), {})).to.equal(query); + }); + + it('applies eq where filters', () => { + const query = { eq: sinon.stub().returnsThis() }; + const result = applyWhere(query, (attr, op) => op.eq(attr.auditType, 'lhs-mobile'), {}); + + expect(result).to.equal(query); + sinon.assert.calledOnceWithExactly(query.eq, 'audit_type', 'lhs-mobile'); + }); + + it('applies contains where filters with scalar and array values', () => { + const query = { contains: sinon.stub().returnsThis() }; + + applyWhere(query, (attr, op) => op.contains(attr.tags, 'seo'), {}); + sinon.assert.calledOnceWithExactly(query.contains, 'tags', ['seo']); + + query.contains.resetHistory(); + applyWhere(query, (attr, op) => op.contains(attr.tags, ['seo', 'ux']), {}); + sinon.assert.calledOnceWithExactly(query.contains, 'tags', ['seo', 'ux']); + }); +}); diff --git a/packages/spacecat-shared-data-access/tsconfig.json b/packages/spacecat-shared-data-access/tsconfig.json new file mode 100644 index 000000000..8233ab805 --- /dev/null +++ b/packages/spacecat-shared-data-access/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} From 52d8ebdfd01a9b730a23010424b0a900cbec6ace Mon Sep 17 00:00:00 2001 From: ekremney Date: Fri, 13 Feb 2026 16:54:26 +0100 Subject: [PATCH 02/20] test(data-access): replace IT suite with PostgREST harness --- .../test/it/api-key/api-key.test.js | 139 - .../test/it/async-job/async-job.test.js | 92 - .../test/it/audit-url/audit-url.test.js | 335 -- .../test/it/audit/audit.test.js | 82 - .../it/configuration/configuration.test.js | 233 -- .../test/it/entitlement/entitlement.test.js | 176 - .../test/it/experiment/experiment.test.js | 167 - .../fix-entity-suggestion.test.js | 1031 ------ .../test/it/fix-entity/fix-entity.test.js | 412 --- .../test/it/fixtures.js | 183 +- .../test/it/import-job/import-job.test.js | 226 -- .../test/it/import-url/import-url.test.js | 131 - .../test/it/key-events/key-events.test.js | 99 - .../test/it/latest-audit/latest-audit.test.js | 209 -- .../test/it/opportunity/opportunity.test.js | 613 ---- .../test/it/organization/organization.test.js | 177 - .../page-citability/page-citability.test.js | 176 - .../test/it/page-intent/page-intent.test.js | 111 - .../test/it/postgrest/docker-compose.yml | 31 + .../test/it/postgrest/postgrest.test.js | 69 + .../test/it/project/project.test.js | 136 - .../test/it/report/report.test.js | 267 -- .../test/it/scrape-job/scrape-job.test.js | 238 -- .../test/it/scrape-url/scrape-url.test.js | 140 - .../test/it/seed/tenants/01_tenant_alpha.sql | 3159 +++++++++++++++++ .../sentiment-guideline.test.js | 316 -- .../sentiment-topic/sentiment-topic.test.js | 253 -- .../it/site-candidate/site-candidate.test.js | 105 - .../site-enrollment/site-enrollment.test.js | 100 - .../it/site-top-form/site-top-form.test.js | 572 --- .../it/site-top-page/site-top-page.test.js | 206 -- .../test/it/site/site.test.js | 990 ------ .../test/it/suggestion/suggestion.test.js | 308 -- .../trial-user-activities.test.js | 160 - .../test/it/trial-user/trial-user.test.js | 178 - .../test/it/util/auditUtils.js | 53 - .../test/it/util/db.js | 86 - .../test/it/util/seed.js | 89 - .../test/it/util/tableOperations.js | 199 -- .../test/it/util/util.js | 55 - 40 files changed, 3389 insertions(+), 8913 deletions(-) delete mode 100644 packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/async-job/async-job.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/audit-url/audit-url.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/audit/audit.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/entitlement/entitlement.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/fix-entity-suggestion/fix-entity-suggestion.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/fix-entity/fix-entity.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/latest-audit/latest-audit.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/organization/organization.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/page-citability/page-citability.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/page-intent/page-intent.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml create mode 100644 packages/spacecat-shared-data-access/test/it/postgrest/postgrest.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/project/project.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/report/report.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/scrape-job/scrape-job.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/scrape-url/scrape-url.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/seed/tenants/01_tenant_alpha.sql delete mode 100644 packages/spacecat-shared-data-access/test/it/sentiment-guideline/sentiment-guideline.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/sentiment-topic/sentiment-topic.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/site-candidate/site-candidate.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/site-enrollment/site-enrollment.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/site-top-form/site-top-form.test.js delete mode 100755 packages/spacecat-shared-data-access/test/it/site-top-page/site-top-page.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/site/site.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/trial-user-activity/trial-user-activities.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/trial-user/trial-user.test.js delete mode 100644 packages/spacecat-shared-data-access/test/it/util/auditUtils.js delete mode 100755 packages/spacecat-shared-data-access/test/it/util/db.js delete mode 100644 packages/spacecat-shared-data-access/test/it/util/seed.js delete mode 100755 packages/spacecat-shared-data-access/test/it/util/tableOperations.js delete mode 100755 packages/spacecat-shared-data-access/test/it/util/util.js diff --git a/packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js b/packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js deleted file mode 100644 index 6d55de498..000000000 --- a/packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; -import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/util/util.js'; - -use(chaiAsPromised); - -describe('ApiKey IT', async () => { - let sampleData; - let ApiKey; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - ApiKey = dataAccess.ApiKey; - }); - - it('adds a new api key', async () => { - const data = { - name: 'Test API Key', - expiresAt: '2025-12-06T08:35:24.125Z', - hashedApiKey: '1234', - imsOrgId: '1234@AdobeOrg', - imsUserId: '1234', - scopes: [ - { name: 'imports.read' }, - { name: 'imports.write', domains: ['https://example.com'] }, - ], - updatedBy: 'system', - }; - - const apiKey = await ApiKey.create(data); - - expect(apiKey).to.be.an('object'); - expect(apiKey.getId()).to.be.a('string'); - expect(apiKey.getCreatedAt()).to.be.a('string'); - expect(apiKey.getUpdatedAt()).to.be.a('string'); - - expect( - sanitizeIdAndAuditFields('ApiKey', apiKey.toJSON()), - ).to.eql(data); - }); - - it('gets all api keys by imsUserId and imsOrgId', async () => { - const sampleApiKey = sampleData.apiKeys[0]; - const apiKeys = await ApiKey.allByImsOrgIdAndImsUserId( - sampleApiKey.getImsOrgId(), - sampleApiKey.getImsUserId(), - ); - - expect(apiKeys).to.be.an('array'); - expect(apiKeys.length).to.equal(2); - - apiKeys.forEach((apiKey) => { - expect(apiKey.getImsOrgId()).to.equal(sampleApiKey.getImsOrgId()); - expect(apiKey.getImsUserId()).to.equal(sampleApiKey.getImsUserId()); - }); - }); - - it('finds an api key by hashedApiKey', async () => { - const sampleApiKey = sampleData.apiKeys[0]; - const apiKey = await ApiKey.findByHashedApiKey(sampleApiKey.getHashedApiKey()); - - expect(apiKey).to.be.an('object'); - - expect( - sanitizeTimestamps(apiKey.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleApiKey.toJSON()), - ); - }); - - it('finds an api key by its id', async () => { - const sampleApiKey = sampleData.apiKeys[0]; - const apiKey = await ApiKey.findById(sampleApiKey.getId()); - - expect(apiKey).to.be.an('object'); - - expect( - sanitizeTimestamps(apiKey.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleApiKey.toJSON()), - ); - }); - - it('updates an api key', async () => { - const apiKey = await ApiKey.findById(sampleData.apiKeys[0].getId()); - - const data = { - name: 'Updated API Key', - expiresAt: '2024-12-06T08:35:24.125Z', - hashedApiKey: '1234', - imsOrgId: '1234@AdobeOrg', - imsUserId: '1234', - scopes: [ - { name: 'imports.write' }, - { name: 'imports.read', domains: ['https://updated-example.com'] }, - ], - updatedBy: 'system', - }; - - const result = await apiKey - .setName(data.name) - .setExpiresAt(data.expiresAt) - .setHashedApiKey(data.hashedApiKey) - .setImsOrgId(data.imsOrgId) - .setImsUserId(data.imsUserId) - .setScopes(data.scopes) - .save(); - - expect(result).to.be.an('object'); - - const updatedApiKey = await ApiKey.findById(sampleData.apiKeys[0].getId()); - - expect(updatedApiKey.getId()).to.equal(apiKey.getId()); - - expect( - sanitizeIdAndAuditFields('ApiKey', updatedApiKey.toJSON()), - ).to.eql(data); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/async-job/async-job.test.js b/packages/spacecat-shared-data-access/test/it/async-job/async-job.test.js deleted file mode 100644 index 8b52a9d1f..000000000 --- a/packages/spacecat-shared-data-access/test/it/async-job/async-job.test.js +++ /dev/null @@ -1,92 +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 } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import { ElectroValidationError } from 'electrodb'; -import AsyncJobModel from '../../../src/models/async-job/async-job.model.js'; -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; -import { DataAccessError } from '../../../src/index.js'; - -use(chaiAsPromised); - -function checkAsyncJob(asyncJob) { - expect(asyncJob).to.be.an('object'); - expect(asyncJob.getStatus()).to.be.a('string'); - expect(asyncJob.getCreatedAt()).to.be.a('string'); - expect(asyncJob.getUpdatedAt()).to.be.a('string'); - expect(asyncJob.getRecordExpiresAt()).to.be.a('number'); - expect(asyncJob.getMetadata()).to.be.an('object'); -} - -describe('AsyncJob IT', async () => { - let sampleData; - let AsyncJob; - let newJobData; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - const dataAccess = getDataAccess(); - AsyncJob = dataAccess.AsyncJob; - newJobData = { - status: 'IN_PROGRESS', - metadata: { submittedBy: 'it-user', jobType: 'test', tags: ['it'] }, - }; - }); - - it('adds a new async job', async () => { - const asyncJob = await AsyncJob.create(newJobData); - checkAsyncJob(asyncJob); - expect(asyncJob.getStatus()).to.equal(newJobData.status); - expect(asyncJob.getMetadata()).to.eql(newJobData.metadata); - }); - - it('updates an existing async job', async () => { - const sampleAsyncJob = sampleData.asyncJobs[0]; - const asyncJob = await AsyncJob.findById(sampleAsyncJob.getId()); - await asyncJob.setStatus('COMPLETED').setResultType('INLINE').setResult({ value: 123 }).save(); - const updatedAsyncJob = await AsyncJob.findById(asyncJob.getId()); - checkAsyncJob(updatedAsyncJob); - expect(updatedAsyncJob.getStatus()).to.equal('COMPLETED'); - expect(updatedAsyncJob.getResultType()).to.equal('INLINE'); - expect(updatedAsyncJob.getResult()).to.eql({ value: 123 }); - }); - - it('finds an async job by its id', async () => { - const sampleAsyncJob = sampleData.asyncJobs[0]; - const asyncJob = await AsyncJob.findById(sampleAsyncJob.getId()); - checkAsyncJob(asyncJob); - expect(asyncJob.getId()).to.equal(sampleAsyncJob.getId()); - }); - - it('gets all async jobs by status', async () => { - const asyncJobs = await AsyncJob.allByStatus(AsyncJobModel.Status.COMPLETED); - expect(asyncJobs).to.be.an('array'); - asyncJobs.forEach((asyncJob) => { - checkAsyncJob(asyncJob); - expect(asyncJob.getStatus()).to.equal(AsyncJobModel.Status.COMPLETED); - }); - }); - - it('throws an error when adding a job with invalid status', async () => { - const data = { ...newJobData, status: 'INVALID_STATUS' }; - await AsyncJob.create(data).catch((err) => { - expect(err).to.be.instanceOf(DataAccessError); - expect(err.cause).to.be.instanceOf(ElectroValidationError); - expect(err.cause.message).to.contain('Invalid value'); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/audit-url/audit-url.test.js b/packages/spacecat-shared-data-access/test/it/audit-url/audit-url.test.js deleted file mode 100644 index a763133ba..000000000 --- a/packages/spacecat-shared-data-access/test/it/audit-url/audit-url.test.js +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -function checkAuditUrl(auditUrl) { - expect(auditUrl).to.be.an('object'); - // Composite primary key: siteId + url - expect(auditUrl.getSiteId()).to.be.a('string'); - expect(auditUrl.getUrl()).to.be.a('string'); - expect(auditUrl.getByCustomer()).to.be.a('boolean'); - expect(auditUrl.getAudits()).to.be.an('array'); - expect(auditUrl.getCreatedAt()).to.be.a('string'); - expect(auditUrl.getCreatedBy()).to.be.a('string'); -} - -// eslint-disable-next-line prefer-arrow-callback -describe('AuditUrl IT', function () { - let sampleData; - let AuditUrl; - - // eslint-disable-next-line prefer-arrow-callback - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - AuditUrl = dataAccess.AuditUrl; - }); - - it('gets all audit URLs for a site', async () => { - const site = sampleData.sites[0]; - - const auditUrls = await AuditUrl.allBySiteId(site.getId()); - - expect(auditUrls).to.be.an('array'); - expect(auditUrls.length).to.equal(3); - - auditUrls.forEach((auditUrl) => { - checkAuditUrl(auditUrl); - expect(auditUrl.getSiteId()).to.equal(site.getId()); - }); - }); - - it('gets all audit URLs for a site by byCustomer flag', async () => { - const site = sampleData.sites[0]; - const byCustomer = true; - - // Use allBySiteIdByCustomerSorted since direct GSI accessor fails for boolean false - const result = await AuditUrl.allBySiteIdByCustomerSorted(site.getId(), byCustomer, {}); - - // Returns { data, cursor } format for pagination - expect(result).to.be.an('object'); - expect(result.data).to.be.an('array'); - expect(result.data.length).to.equal(2); - - result.data.forEach((auditUrl) => { - checkAuditUrl(auditUrl); - expect(auditUrl.getSiteId()).to.equal(site.getId()); - expect(auditUrl.getByCustomer()).to.equal(byCustomer); - }); - }); - - it('finds an audit URL by site ID and URL (GSI lookup)', async () => { - const site = sampleData.sites[0]; - const url = 'https://example0.com/page-1'; - - const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), url); - - expect(auditUrl).to.be.an('object'); - checkAuditUrl(auditUrl); - expect(auditUrl.getSiteId()).to.equal(site.getId()); - expect(auditUrl.getUrl()).to.equal(url); - }); - - it('returns null when audit URL not found', async () => { - const site = sampleData.sites[0]; - const url = 'https://example0.com/nonexistent'; - - const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), url); - - expect(auditUrl).to.be.null; - }); - - it('creates a new audit URL', async () => { - const site = sampleData.sites[0]; - const data = { - siteId: site.getId(), - url: 'https://example0.com/new-page', - byCustomer: true, - audits: ['accessibility', 'broken-backlinks'], - createdBy: 'test@example.com', - }; - - const auditUrl = await AuditUrl.create(data); - - checkAuditUrl(auditUrl); - expect(auditUrl.getSiteId()).to.equal(data.siteId); - expect(auditUrl.getUrl()).to.equal(data.url); - expect(auditUrl.getByCustomer()).to.equal(data.byCustomer); - expect(auditUrl.getAudits()).to.deep.equal(data.audits); - expect(auditUrl.getCreatedBy()).to.equal(data.createdBy); - }); - - it('creates an audit URL with default values', async () => { - const site = sampleData.sites[0]; - const data = { - siteId: site.getId(), - url: 'https://example0.com/default-page', - createdBy: 'test@example.com', - }; - - const auditUrl = await AuditUrl.create(data); - - checkAuditUrl(auditUrl); - expect(auditUrl.getByCustomer()).to.equal(true); // Default - expect(auditUrl.getAudits()).to.deep.equal([]); // Default - }); - - it('finds an audit URL by composite primary key (siteId + url)', async () => { - const site = sampleData.sites[0]; - const data = { - siteId: site.getId(), - url: 'https://example0.com/findbyid-page', - byCustomer: true, - audits: ['accessibility'], - createdBy: 'test@example.com', - }; - - const created = await AuditUrl.create(data); - const siteId = created.getSiteId(); - const url = created.getUrl(); - - // findById uses composite key (siteId + url) - const found = await AuditUrl.findById(siteId, url); - - expect(found).to.not.be.null; - checkAuditUrl(found); - expect(found.getSiteId()).to.equal(siteId); - expect(found.getUrl()).to.equal(url); - }); - - it('updates an audit URL', async () => { - const site = sampleData.sites[0]; - const url = 'https://example0.com/page-1'; - const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), url); - - auditUrl.setAudits(['accessibility']); - auditUrl.setUpdatedBy('updater@example.com'); - - const updated = await auditUrl.save(); - - expect(updated.getAudits()).to.deep.equal(['accessibility']); - expect(updated.getUpdatedBy()).to.equal('updater@example.com'); - }); - - it('removes an audit URL', async () => { - const site = sampleData.sites[0]; - const data = { - siteId: site.getId(), - url: 'https://example0.com/to-delete', - byCustomer: true, - audits: ['accessibility'], - createdBy: 'test@example.com', - }; - - const auditUrl = await AuditUrl.create(data); - const siteId = auditUrl.getSiteId(); - const url = auditUrl.getUrl(); - - await auditUrl.remove(); - - const deleted = await AuditUrl.findBySiteIdAndUrl(siteId, url); - expect(deleted).to.be.null; - }); - - describe('Custom Methods', () => { - it('checks if an audit is enabled', async () => { - const site = sampleData.sites[0]; - const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); - - expect(auditUrl.isAuditEnabled('accessibility')).to.be.true; - expect(auditUrl.isAuditEnabled('lhs-mobile')).to.be.false; - }); - - it('enables an audit', async () => { - const site = sampleData.sites[0]; - const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); - const originalAudits = auditUrl.getAudits(); - - auditUrl.enableAudit('lhs-mobile'); - - expect(auditUrl.getAudits()).to.include('lhs-mobile'); - expect(auditUrl.getAudits().length).to.equal(originalAudits.length + 1); - }); - - it('does not duplicate audits when enabling', async () => { - const site = sampleData.sites[0]; - const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); - const originalLength = auditUrl.getAudits().length; - - auditUrl.enableAudit('accessibility'); // Already enabled - - expect(auditUrl.getAudits().length).to.equal(originalLength); - }); - - it('disables an audit', async () => { - const site = sampleData.sites[0]; - const auditUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); - - auditUrl.disableAudit('accessibility'); - - expect(auditUrl.getAudits()).to.not.include('accessibility'); - }); - - it('checks if URL is customer-added', async () => { - const site = sampleData.sites[0]; - const customerUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-1'); - const systemUrl = await AuditUrl.findBySiteIdAndUrl(site.getId(), 'https://example0.com/page-2'); - - expect(customerUrl.isCustomerUrl()).to.be.true; - expect(systemUrl.isCustomerUrl()).to.be.false; - }); - }); - - describe('Collection Methods', () => { - it('gets all audit URLs by audit type', async () => { - const site = sampleData.sites[0]; - - const result = await AuditUrl.allBySiteIdAndAuditType( - site.getId(), - 'accessibility', - ); - - // Returns { data, cursor } format for pagination - expect(result).to.be.an('object'); - expect(result.data).to.be.an('array'); - // Fixture has 2 URLs with 'accessibility', plus tests add more: - // - "creates a new audit URL" adds 1 - // - "finds an audit URL by composite key" adds 1 - expect(result.data.length).to.equal(4); - - result.data.forEach((auditUrl) => { - expect(auditUrl.isAuditEnabled('accessibility')).to.be.true; - }); - }); - - it('removes all audit URLs for a site', async () => { - const site = sampleData.sites[2]; - - // Verify URLs exist - let auditUrls = await AuditUrl.allBySiteId(site.getId()); - expect(auditUrls.length).to.be.greaterThan(0); - - // Remove all - await AuditUrl.removeForSiteId(site.getId()); - - // Verify removed - auditUrls = await AuditUrl.allBySiteId(site.getId()); - expect(auditUrls.length).to.equal(0); - }); - - it('removes audit URLs by byCustomer flag', async () => { - const site = sampleData.sites[0]; - - // Remove all customer-added URLs - await AuditUrl.removeForSiteIdByCustomer(site.getId(), true); - - // Verify only system-added URLs remain - const auditUrls = await AuditUrl.allBySiteId(site.getId()); - auditUrls.forEach((auditUrl) => { - expect(auditUrl.getByCustomer()).to.equal(false); - }); - }); - }); - - describe('Validation', () => { - it('rejects invalid UUID for siteId', async () => { - const data = { - siteId: 'invalid-uuid', - url: 'https://example.com/page', - createdBy: 'test@example.com', - }; - - await expect(AuditUrl.create(data)).to.be.rejected; - }); - - it('rejects invalid URL format', async () => { - const site = sampleData.sites[0]; - const data = { - siteId: site.getId(), - url: 'not-a-valid-url', - createdBy: 'test@example.com', - }; - - await expect(AuditUrl.create(data)).to.be.rejected; - }); - - it('requires siteId', async () => { - const data = { - url: 'https://example.com/page', - createdBy: 'test@example.com', - }; - - await expect(AuditUrl.create(data)).to.be.rejected; - }); - - it('requires url', async () => { - const site = sampleData.sites[0]; - const data = { - siteId: site.getId(), - createdBy: 'test@example.com', - }; - - await expect(AuditUrl.create(data)).to.be.rejected; - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/audit/audit.test.js b/packages/spacecat-shared-data-access/test/it/audit/audit.test.js deleted file mode 100644 index 846901eab..000000000 --- a/packages/spacecat-shared-data-access/test/it/audit/audit.test.js +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -function checkAudit(audit) { - expect(audit).to.be.an('object'); - expect(audit.getId()).to.be.a('string'); - expect(audit.getSiteId()).to.be.a('string'); - expect(audit.getAuditType()).to.be.a('string'); - expect(audit.getAuditedAt()).to.be.a('string'); - expect(audit.getAuditResult()).to.be.an('object'); - expect(audit.getScores()).to.be.an('object'); - expect(audit.getFullAuditRef()).to.be.a('string'); - expect(audit.getIsLive()).to.be.a('boolean'); -} - -describe('Audit IT', async () => { - let sampleData; - let Audit; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - Audit = dataAccess.Audit; - }); - - it('gets all audits for a site', async () => { - const site = sampleData.sites[1]; - - const audits = await Audit.allBySiteId(site.getId()); - - expect(audits).to.be.an('array'); - expect(audits.length).to.equal(10); - - audits.forEach((audit) => { - expect(audit.getSiteId()).to.equal(site.getId()); - checkAudit(audit); - }); - }); - - it('gets audits of type for a site', async () => { - const auditType = 'lhs-mobile'; - const site = sampleData.sites[1]; - - const audits = await Audit.allBySiteIdAndAuditType(site.getId(), auditType); - - expect(audits).to.be.an('array'); - expect(audits.length).to.equal(5); - - audits.forEach((audit) => { - expect(audit.getSiteId()).to.equal(site.getId()); - expect(audit.getAuditType()).to.equal(auditType); - checkAudit(audit); - }); - }); - - it('returns null for non-existing audit', async () => { - const audit = await Audit.findById('78fec9c7-2141-4600-b7b1-ea4c78752b91'); - - expect(audit).to.be.null; - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js b/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js deleted file mode 100644 index d0d400fac..000000000 --- a/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } 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); - -/** - * Creates a mock S3 response body without using sdkStreamMixin. - * @param {object} data - The data to return as JSON. - * @returns {object} Mock body with transformToString method. - */ -const createMockS3Body = (data) => ({ - transformToString: async () => JSON.stringify(data), -}); - -describe('Configuration IT', async () => { - let Configuration; - let mockS3Client; - let s3Storage; // Stores configurations by VersionId - - // Sample configuration data for testing - const sampleConfigData = { - queues: { - audits: 'audit-queue-url', - imports: 'import-queue-url', - }, - jobs: [ - { - group: 'audits', - type: 'cwv', - interval: 'daily', - }, - ], - handlers: { - cwv: { - enabledByDefault: true, - dependencies: [], - disabled: { sites: [], orgs: [] }, - enabled: { sites: [], orgs: [] }, - productCodes: ['CDN'], - }, - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - before(async function () { - this.timeout(10000); - await seedDatabase(); - - const dataAccess = getDataAccess(); - Configuration = dataAccess.Configuration; - }); - - beforeEach(() => { - // Reset S3 storage for each test - s3Storage = new Map(); - - // Create mock S3 client - mockS3Client = { - send: sinon.stub().callsFake(async (command) => { - const commandName = command.constructor.name; - - if (commandName === 'PutObjectCommand') { - const versionId = `version-${Date.now()}-${Math.random().toString(36).substring(7)}`; - const configData = JSON.parse(command.input.Body); - s3Storage.set(versionId, configData); - return { VersionId: versionId }; - } - - if (commandName === 'GetObjectCommand') { - const { VersionId } = command.input; - - if (VersionId) { - // Fetch specific version - const data = s3Storage.get(VersionId); - if (!data) { - const error = new Error('NoSuchVersion'); - error.name = 'NoSuchVersion'; - throw error; - } - return { Body: createMockS3Body(data), VersionId }; - } - - // Fetch latest version - if (s3Storage.size === 0) { - const error = new Error('NoSuchKey'); - error.name = 'NoSuchKey'; - throw error; - } - const latestVersionId = Array.from(s3Storage.keys()).pop(); - return { - Body: createMockS3Body(s3Storage.get(latestVersionId)), - VersionId: latestVersionId, - }; - } - - return {}; - }), - }; - - // Inject mock S3 client into Configuration collection - Configuration.s3Client = mockS3Client; - Configuration.s3Bucket = 'test-bucket'; - }); - - afterEach(() => { - sinon.restore(); - }); - - it('creates a new configuration in S3', async () => { - const configuration = await Configuration.create(sampleConfigData); - - expect(configuration).to.be.an('object'); - expect(configuration.getId()).to.be.a('string'); - expect(s3Storage.size).to.equal(1); - }); - - it('finds the latest configuration', async () => { - // Create a configuration first - const created = await Configuration.create(sampleConfigData); - - const configuration = await Configuration.findLatest(); - - expect(configuration).to.be.an('object'); - expect(configuration.getId()).to.equal(created.getId()); - }); - - it('finds configuration by version (S3 VersionId)', async () => { - const created = await Configuration.create(sampleConfigData); - const versionId = created.getId(); - - const configuration = await Configuration.findByVersion(versionId); - - expect(configuration).to.be.an('object'); - expect(configuration.getId()).to.equal(versionId); - }); - - it('returns null when configuration version not found', async () => { - const configuration = await Configuration.findByVersion('non-existent-version'); - - expect(configuration).to.be.null; - }); - - it('returns null when no configuration exists', async () => { - const configuration = await Configuration.findLatest(); - - expect(configuration).to.be.null; - }); - - it('updates a configuration (creates new version)', async () => { - const configuration = await Configuration.create(sampleConfigData); - const originalId = configuration.getId(); - - const handlerData = { - enabledByDefault: true, - dependencies: [], - disabled: { sites: [], orgs: [] }, - enabled: { sites: ['site1'], orgs: ['org1'] }, - productCodes: ['ASO'], - }; - - configuration.addHandler('test', handlerData); - await configuration.save(); - - // A new version should be created - const updatedConfiguration = await Configuration.findLatest(); - expect(updatedConfiguration.getId()).to.not.equal(originalId); - expect(updatedConfiguration.getHandler('test')).to.deep.equal(handlerData); - expect(s3Storage.size).to.equal(2); - }); - - it('registers a new audit handler', async () => { - await Configuration.create(sampleConfigData); - - const configuration = await Configuration.findLatest(); - configuration.registerAudit('structured-data', true, 'weekly', ['LLMO']); - await configuration.save(); - - const updatedConfiguration = await Configuration.findLatest(); - expect(updatedConfiguration.getHandler('structured-data')).to.deep.equal({ - enabledByDefault: true, - dependencies: [], - disabled: { sites: [], orgs: [] }, - enabled: { sites: [], orgs: [] }, - productCodes: ['LLMO'], - }); - }); - - it('unregisters an audit handler', async () => { - // Create config with a handler - const configWithHandler = { - ...sampleConfigData, - handlers: { - ...sampleConfigData.handlers, - 'structured-data': { - enabledByDefault: true, - dependencies: [], - disabled: { sites: [], orgs: [] }, - enabled: { sites: [], orgs: [] }, - productCodes: ['LLMO'], - }, - }, - }; - await Configuration.create(configWithHandler); - - const configuration = await Configuration.findLatest(); - expect(configuration.getHandler('structured-data')).to.not.be.undefined; - - configuration.unregisterAudit('structured-data'); - await configuration.save(); - - const updatedConfiguration = await Configuration.findLatest(); - expect(updatedConfiguration.getHandler('structured-data')).to.be.undefined; - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/entitlement/entitlement.test.js b/packages/spacecat-shared-data-access/test/it/entitlement/entitlement.test.js deleted file mode 100644 index 273124c05..000000000 --- a/packages/spacecat-shared-data-access/test/it/entitlement/entitlement.test.js +++ /dev/null @@ -1,176 +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 } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/util/util.js'; -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -describe('Entitlement IT', async () => { - let sampleData; - let Entitlement; - let SiteEnrollment; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - Entitlement = dataAccess.Entitlement; - SiteEnrollment = dataAccess.SiteEnrollment; - }); - - it('gets an entitlement by id', async () => { - const sampleEntitlement = sampleData.entitlements[0]; - const entitlement = await Entitlement.findById(sampleEntitlement.getId()); - - expect(entitlement).to.be.an('object'); - expect( - sanitizeTimestamps(entitlement.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleEntitlement.toJSON()), - ); - }); - - it('gets all entitlements by organization id', async () => { - const sampleEntitlement = sampleData.entitlements[0]; - const organizationId = sampleEntitlement.getOrganizationId(); - - const entitlements = await Entitlement.allByOrganizationId(organizationId); - - expect(entitlements).to.be.an('array'); - expect(entitlements.length).to.be.greaterThan(0); - - for (const entitlement of entitlements) { - expect(entitlement.getOrganizationId()).to.equal(organizationId); - } - }); - - it('gets all entitlements by organization id and product code', async () => { - const sampleEntitlement = sampleData.entitlements[0]; - const organizationId = sampleEntitlement.getOrganizationId(); - const productCode = sampleEntitlement.getProductCode(); - - const entitlements = await Entitlement.allByOrganizationIdAndProductCode( - organizationId, - productCode, - ); - - expect(entitlements).to.be.an('array'); - expect(entitlements.length).to.be.greaterThan(0); - - for (const entitlement of entitlements) { - expect(entitlement.getOrganizationId()).to.equal(organizationId); - expect(entitlement.getProductCode()).to.equal(productCode); - } - }); - - it('adds a new entitlement', async () => { - const data = { - organizationId: sampleData.organizations[0].getId(), - productCode: 'LLMO', - tier: 'FREE_TRIAL', - quotas: { - llmo_trial_prompts: 500, - }, - updatedBy: 'system', - }; - - const entitlement = await Entitlement.create(data); - - expect(entitlement).to.be.an('object'); - - expect( - sanitizeIdAndAuditFields('Entitlement', entitlement.toJSON()), - ).to.eql(data); - }); - - it('updates the quota of an entitlement', async () => { - const entitlement = await Entitlement.findById(sampleData.entitlements[0].getId()); - - const newQuotas = { - llmo_trial_prompts: 300, - }; - const expectedEntitlement = { - ...entitlement.toJSON(), - quotas: newQuotas, - }; - entitlement.setQuotas(newQuotas); - await entitlement.save(); - - const updatedEntitlement = await Entitlement.findById(entitlement.getId()); - expect(updatedEntitlement.getId()).to.equal(entitlement.getId()); - expect(updatedEntitlement.record.createdAt).to.equal(entitlement.record.createdAt); - expect(updatedEntitlement.record.updatedAt).to.not.equal(entitlement.record.updatedAt); - expect( - sanitizeIdAndAuditFields('Entitlement', updatedEntitlement.toJSON()), - ).to.eql( - sanitizeIdAndAuditFields('Entitlement', expectedEntitlement), - ); - }); - - it('updates an entitlement tier', async () => { - const entitlement = await Entitlement.findById(sampleData.entitlements[0].getId()); - const newTier = 'PAID'; - - const expectedEntitlement = { - ...entitlement.toJSON(), - tier: newTier, - }; - entitlement.setTier(newTier); - await entitlement.save(); - - const updatedEntitlement = await Entitlement.findById(entitlement.getId()); - - expect(updatedEntitlement.getId()).to.equal(entitlement.getId()); - expect(updatedEntitlement.record.createdAt).to.equal(entitlement.record.createdAt); - expect(updatedEntitlement.record.updatedAt).to.not.equal(entitlement.record.updatedAt); - expect( - sanitizeIdAndAuditFields('Entitlement', updatedEntitlement.toJSON()), - ).to.eql( - sanitizeIdAndAuditFields('Entitlement', expectedEntitlement), - ); - }); - - it('removes an entitlement', async () => { - const entitlement = await Entitlement.findById(sampleData.entitlements[0].getId()); - - await entitlement.remove(); - - const notFound = await Entitlement.findById(sampleData.entitlements[0].getId()); - expect(notFound).to.be.null; - }); - - it('removes an entitlement and its dependent site enrollments', async () => { - const entitlement = await Entitlement.findById(sampleData.entitlements[1].getId()); - const siteEnrollments = await entitlement.getSiteEnrollments(); - - expect(siteEnrollments).to.be.an('array').with.length.greaterThan(0); - - await entitlement.remove(); - - const notFound = await Entitlement.findById(sampleData.entitlements[1].getId()); - expect(notFound).to.be.null; - - // verify that dependent site enrollments are removed as well - await Promise.all(siteEnrollments.map(async (siteEnrollment) => { - const notFoundEnrollment = await SiteEnrollment.findById(siteEnrollment.getId()); - expect(notFoundEnrollment).to.be.null; - })); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js b/packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js deleted file mode 100644 index 82daedda6..000000000 --- a/packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; -import { sanitizeIdAndAuditFields } from '../../../src/util/util.js'; - -use(chaiAsPromised); - -function checkExperiment(experiment) { - expect(experiment).to.be.an('object'); - expect(experiment.getId()).to.be.a('string'); - expect(experiment.getCreatedAt()).to.be.a('string'); - expect(experiment.getUpdatedAt()).to.be.a('string'); - expect(experiment.getEndDate()).to.be.a('string'); - expect(experiment.getExpId()).to.be.a('string'); - expect(experiment.getName()).to.be.a('string'); - expect(experiment.getStatus()).to.be.a('string'); - expect(experiment.getStartDate()).to.be.a('string'); - expect(experiment.getType()).to.be.a('string'); - expect(experiment.getUrl()).to.be.a('string'); - expect(experiment.getVariants()).to.be.an('array'); -} - -describe('Experiment IT', async () => { - let sampleData; - let Experiment; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - Experiment = dataAccess.Experiment; - }); - - it('gets all experiments for a site', async () => { - const site = sampleData.sites[0]; - - const experiments = await Experiment.allBySiteId(site.getId()); - - expect(experiments).to.be.an('array'); - expect(experiments.length).to.equal(3); - - experiments.forEach((experiment) => { - expect(experiment.getSiteId()).to.equal(site.getId()); - checkExperiment(experiment); - }); - }); - - it('gets all experiments for a site and expId', async () => { - const site = sampleData.sites[0]; - const expId = 'experiment-1'; - - const experiments = await Experiment.allBySiteIdAndExpId(site.getId(), expId); - - expect(experiments).to.be.an('array'); - expect(experiments.length).to.equal(1); - - const experiment = experiments[0]; - expect(experiment.getSiteId()).to.equal(site.getId()); - checkExperiment(experiment); - }); - - it('returns empty array for a site with no experiments', async () => { - const site = sampleData.sites[1]; - - const experiments = await Experiment.allBySiteId(site.getId()); - - expect(experiments).to.be.an('array'); - expect(experiments.length).to.equal(0); - }); - - it('finds one experiment by siteId, expId and url', async () => { - const site = sampleData.sites[0]; - const expId = 'experiment-1'; - const url = 'https://example0.com/page-1'; - - const experiment = await Experiment.findBySiteIdAndExpId(site.getId(), expId, url); - - checkExperiment(experiment); - expect(experiment.getUrl()).to.equal(url); - }); - - it('adds a new experiment to a site', async () => { - const site = sampleData.sites[0]; - const experimentData = { - siteId: site.getId(), - expId: 'experiment-4', - name: 'Experiment 4', - url: 'https://example0.com/page-4', - status: 'ACTIVE', - type: 'full', - startDate: '2024-12-06T08:35:24.125Z', - endDate: '2025-12-06T08:35:24.125Z', - variants: [ - { - label: 'Challenger 1', - name: 'challenger-1', - interactionsCount: 10, - p_value: 'coming soon', - split: 0.8, - url: 'https://example0.com/page-4/variant-1', - views: 100, - metrics: [ - { - selector: '.header .button', - type: 'click', - value: 2, - }, - ], - }, - { - label: 'Challenger 2', - name: 'challenger-2', - interactionsCount: 20, - p_value: 'coming soon', - metrics: [], - split: 0.8, - url: 'https://example0.com/page-4/variant-2', - views: 200, - }, - ], - updatedBy: 'scheduled-experiment-audit', - }; - - const addedExperiment = await Experiment.create(experimentData); - - checkExperiment(addedExperiment); - - expect(sanitizeIdAndAuditFields('Experiment', addedExperiment.toJSON())).to.eql(experimentData); - }); - - it('updates an existing experiment', async () => { - const site = sampleData.sites[0]; - const expId = 'experiment-1'; - const url = 'https://example0.com/page-1'; - const updates = { - name: 'Updated Experiment 1', - url: 'https://example0.com/page-1/updated', - }; - - const experiment = await Experiment.findBySiteIdAndExpIdAndUrl(site.getId(), expId, url); - experiment.setName(updates.name); - experiment.setUrl(updates.url); - - await experiment.save(); - - checkExperiment(experiment); - expect(experiment.getName()).to.equal(updates.name); - expect(experiment.getUrl()).to.equal(updates.url); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/fix-entity-suggestion/fix-entity-suggestion.test.js b/packages/spacecat-shared-data-access/test/it/fix-entity-suggestion/fix-entity-suggestion.test.js deleted file mode 100644 index 43bf15739..000000000 --- a/packages/spacecat-shared-data-access/test/it/fix-entity-suggestion/fix-entity-suggestion.test.js +++ /dev/null @@ -1,1031 +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 } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import sinon from 'sinon'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -describe('FixEntity-Suggestion Many-to-Many Relationship IT', async () => { - let sampleData; - let FixEntity; - let Suggestion; - let FixEntitySuggestion; - let mockLogger; - - beforeEach(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - mockLogger = { - debug: sinon.stub(), - error: sinon.stub(), - info: sinon.stub(), - warn: sinon.stub(), - }; - - const dataAccess = getDataAccess({}, mockLogger); - FixEntity = dataAccess.FixEntity; - Suggestion = dataAccess.Suggestion; - FixEntitySuggestion = dataAccess.FixEntitySuggestion; - }); - - afterEach(() => { - sinon.restore(); - }); - - it('sets suggestions for a fix entity using suggestion IDs', async () => { - const fixEntity = sampleData.fixEntities[0]; - const suggestions = [ - sampleData.suggestions[0], - sampleData.suggestions[1], - sampleData.suggestions[2], - ]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - const result = await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - suggestions, - ); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(3); - expect(result.errorItems).to.be.an('array').with.length(0); - expect(result.removedCount).to.equal(0); - - // Verify the relationships were created - result.createdItems.forEach((item, index) => { - expect(item.getFixEntityId()).to.equal(fixEntity.getId()); - expect(item.getSuggestionId()).to.equal(suggestions[index].getId()); - }); - }); - - it('sets suggestions for a fix entity using suggestion objects', async () => { - const fixEntity = sampleData.fixEntities[1]; - const suggestions = [ - sampleData.suggestions[3], - sampleData.suggestions[4], - ]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - const result = await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - suggestions, - ); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(2); - expect(result.errorItems).to.be.an('array').with.length(0); - expect(result.removedCount).to.equal(0); - - // Verify the relationships were created - result.createdItems.forEach((item, index) => { - expect(item.getFixEntityId()).to.equal(fixEntity.getId()); - expect(item.getSuggestionId()).to.equal(suggestions[index].getId()); - }); - }); - - it('updates suggestions for a fix entity (removes old, adds new)', async () => { - const fixEntity = sampleData.fixEntities[0]; - const initialSuggestions = [ - sampleData.suggestions[0], - sampleData.suggestions[1], - ]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - // First, set initial suggestions - await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - initialSuggestions, - ); - - // Then update with different suggestions - const newSuggestions = [ - sampleData.suggestions[1], // Keep this one - sampleData.suggestions[2], // Add this one - sampleData.suggestions[3], // Add this one - ]; - - const result = await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - newSuggestions, - ); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(2); // Added 2 new - expect(result.errorItems).to.be.an('array').with.length(0); - expect(result.removedCount).to.equal(1); // Removed 1 old - - // Verify final state - const finalSuggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(finalSuggestions).to.be.an('array').with.length(3); - - const finalSuggestionIds = finalSuggestions.map((s) => s.getId()).sort(); - const newSuggestionIds = newSuggestions.map((s) => s.getId()).sort(); - expect(finalSuggestionIds).to.deep.equal(newSuggestionIds); - }); - - it('sets empty array to remove all suggestions from a fix entity', async () => { - const fixEntity = sampleData.fixEntities[1]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - // First add some suggestions - await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - [sampleData.suggestions[0], sampleData.suggestions[1]], - ); - - // Then remove all by setting empty array - const result = await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - [], - ); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(0); - expect(result.errorItems).to.be.an('array').with.length(0); - expect(result.removedCount).to.equal(2); - - // Verify no suggestions remain - const finalSuggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(finalSuggestions).to.be.an('array').with.length(0); - }); - - it('throws error when opportunityId is not provided', async () => { - const fixEntity = sampleData.fixEntities[0]; - await expect( - FixEntity.setSuggestionsForFixEntity(null, fixEntity, []), - ).to.be.rejectedWith('Validation failed in FixEntityCollection: opportunityId must be a valid UUID'); - }); - - it('gets all suggestions for a fix entity', async () => { - const fixEntity = sampleData.fixEntities[0]; - const suggestions = [ - sampleData.suggestions[0], - sampleData.suggestions[1], - ]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - // First set up the relationships - await FixEntity.setSuggestionsForFixEntity(opportunity.getId(), fixEntity, suggestions); - - // Then retrieve them - const result = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - - expect(result).to.be.an('array').with.length(2); - - // Verify the suggestions are correct - const retrievedIds = result.map((s) => s.getId()).sort(); - const suggestionIds = suggestions.map((s) => s.getId()).sort(); - expect(retrievedIds).to.deep.equal(suggestionIds); - - // Verify they are proper suggestion objects - result.forEach((suggestion) => { - expect(suggestion).to.be.an('object'); - expect(suggestion.getId()).to.be.a('string'); - expect(suggestion.getOpportunityId()).to.be.a('string'); - expect(suggestion.getType()).to.be.a('string'); - expect(suggestion.getStatus()).to.be.a('string'); - }); - }); - - it('returns empty array when fix entity has no suggestions', async () => { - const fixEntity = sampleData.fixEntities[2]; - - const result = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - - expect(result).to.be.an('array').with.length(0); - }); - - it('throws error when fixEntityId is not provided', async () => { - await expect( - FixEntity.getSuggestionsByFixEntityId(null), - ).to.be.rejectedWith('Validation failed in FixEntityCollection: fixEntityId must be a valid UUID'); - }); - - it('gets all fix entities for a suggestion', async () => { - const suggestion = sampleData.suggestions[0]; - const fixEntityIds = [ - sampleData.fixEntities[0].getId(), - sampleData.fixEntities[1].getId(), - ]; - - // First set up the relationships using direct junction records - const junctionData = fixEntityIds.map((fixEntityId, index) => { - const fixEntity = sampleData.fixEntities[index]; - return { - suggestionId: suggestion.getId(), - fixEntityId, - opportunityId: fixEntity.getOpportunityId(), - fixEntityCreatedAt: fixEntity.getExecutedAt() || fixEntity.getCreatedAt(), - }; - }); - await FixEntitySuggestion.createMany(junctionData); - - // Then retrieve them - const result = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); - - expect(result).to.be.an('array').with.length(2); - - // Verify the fix entities are correct - const retrievedIds = result.map((f) => f.getId()).sort(); - expect(retrievedIds).to.deep.equal(fixEntityIds.sort()); - - // Verify they are proper fix entity objects - result.forEach((fixEntity) => { - expect(fixEntity).to.be.an('object'); - expect(fixEntity.getId()).to.be.a('string'); - expect(fixEntity.getOpportunityId()).to.be.a('string'); - expect(fixEntity.getType()).to.be.a('string'); - expect(fixEntity.getStatus()).to.be.a('string'); - }); - }); - - it('returns empty array when suggestion has no fix entities', async () => { - const suggestion = sampleData.suggestions[8]; - - const result = await Suggestion.getFixEntitiesBySuggestionId(suggestion.getId()); - - expect(result).to.be.an('array').with.length(0); - }); - - it('throws error when suggestionId is not provided', async () => { - await expect( - Suggestion.getFixEntitiesBySuggestionId(null), - ).to.be.rejectedWith('Validation failed in SuggestionCollection: suggestionId must be a valid UUID'); - }); - - it('creates junction records directly', async () => { - const fixEntity0 = sampleData.fixEntities[0]; - const fixEntity1 = sampleData.fixEntities[1]; - const junctionData = [ - { - suggestionId: sampleData.suggestions[0].getId(), - fixEntityId: fixEntity0.getId(), - opportunityId: fixEntity0.getOpportunityId(), - fixEntityCreatedAt: fixEntity0.getExecutedAt() || fixEntity0.getCreatedAt(), - }, - { - suggestionId: sampleData.suggestions[1].getId(), - fixEntityId: fixEntity1.getId(), - opportunityId: fixEntity1.getOpportunityId(), - fixEntityCreatedAt: fixEntity1.getExecutedAt() || fixEntity1.getCreatedAt(), - }, - ]; - - const result = await FixEntitySuggestion.createMany(junctionData); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(2); - expect(result.errorItems).to.be.an('array').with.length(0); - - result.createdItems.forEach((item, index) => { - expect(item.getSuggestionId()).to.equal(junctionData[index].suggestionId); - expect(item.getFixEntityId()).to.equal(junctionData[index].fixEntityId); - }); - }); - - it('gets junction records by suggestion ID', async () => { - const suggestionId = sampleData.suggestions[0].getId(); - const fixEntity = sampleData.fixEntities[0]; - - // Create a junction record first - await FixEntitySuggestion.create({ - suggestionId, - fixEntityId: fixEntity.getId(), - opportunityId: fixEntity.getOpportunityId(), - fixEntityCreatedAt: fixEntity.getExecutedAt() || fixEntity.getCreatedAt(), - }); - - const junctionRecords = await FixEntitySuggestion.allBySuggestionId(suggestionId); - - expect(junctionRecords).to.be.an('array'); - expect(junctionRecords.length).to.be.greaterThan(0); - - junctionRecords.forEach((record) => { - expect(record.getSuggestionId()).to.equal(suggestionId); - expect(record.getFixEntityId()).to.be.a('string'); - }); - }); - - it('gets junction records by fix entity ID', async () => { - const fixEntity = sampleData.fixEntities[0]; - const fixEntityId = fixEntity.getId(); - - // Create a junction record first - await FixEntitySuggestion.create({ - suggestionId: sampleData.suggestions[0].getId(), - fixEntityId, - opportunityId: fixEntity.getOpportunityId(), - fixEntityCreatedAt: fixEntity.getExecutedAt() || fixEntity.getCreatedAt(), - }); - - const junctionRecords = await FixEntitySuggestion.allByFixEntityId(fixEntityId); - - expect(junctionRecords).to.be.an('array'); - expect(junctionRecords.length).to.be.greaterThan(0); - - junctionRecords.forEach((record) => { - expect(record.getFixEntityId()).to.equal(fixEntityId); - expect(record.getSuggestionId()).to.be.a('string'); - }); - }); - - it('handles mixed valid and invalid suggestion IDs gracefully', async () => { - const fixEntity = sampleData.fixEntities[0]; - const mixedSuggestions = [ - sampleData.suggestions[0], // Valid - { getId: () => 'invalid-suggestion-id' }, // Invalid - sampleData.suggestions[1], // Valid - ]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - // This should not throw an error, but should handle validation at the junction level - const result = await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - mixedSuggestions, - ); - - // The behavior depends on validation - some items might be created, others might error - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array'); - expect(result.errorItems).to.be.an('array'); - }); - - it('handles duplicate suggestion IDs in the input array', async () => { - const fixEntity = sampleData.fixEntities[1]; - const duplicateSuggestions = [ - sampleData.suggestions[0], - sampleData.suggestions[1], - sampleData.suggestions[0], // Duplicate - ]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - const result = await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - duplicateSuggestions, - ); - - // Should only create unique relationships - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(2); - expect(result.errorItems).to.be.an('array').with.length(0); - }); - - it('handles setting the same suggestions multiple times (idempotent)', async () => { - const fixEntity = sampleData.fixEntities[2]; - const suggestions = [ - sampleData.suggestions[0], - sampleData.suggestions[1], - ]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - // Set suggestions first time - const result1 = await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - suggestions, - ); - - expect(result1.createdItems).to.be.an('array').with.length(2); - expect(result1.removedCount).to.equal(0); - - // Set the same suggestions again - const result2 = await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - suggestions, - ); - - expect(result2.createdItems).to.be.an('array').with.length(0); - expect(result2.removedCount).to.equal(0); - - // Verify final state - const finalSuggestions = await FixEntity.getSuggestionsByFixEntityId(fixEntity.getId()); - expect(finalSuggestions).to.be.an('array').with.length(2); - }); - - it('maintains consistency when setting relationships from both sides', async () => { - const fixEntity = sampleData.fixEntities[0]; - const suggestion = sampleData.suggestions[0]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - // Set relationship from FixEntity side - await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - [suggestion], - ); - - // Verify from Suggestion side - const fixEntitiesFromSuggestion = await Suggestion.getFixEntitiesBySuggestionId( - suggestion.getId(), - ); - expect(fixEntitiesFromSuggestion).to.be.an('array').with.length(1); - expect(fixEntitiesFromSuggestion[0].getId()).to.equal(fixEntity.getId()); - - // Set additional relationship from FixEntity side (using second fix entity) - const opportunity2 = { getId: () => sampleData.fixEntities[1].getOpportunityId() }; - await FixEntity.setSuggestionsForFixEntity( - opportunity2.getId(), - sampleData.fixEntities[1], - [suggestion], - ); - - // Verify from FixEntity side - const suggestionsFromFixEntity1 = await FixEntity.getSuggestionsByFixEntityId( - fixEntity.getId(), - ); - const suggestionsFromFixEntity2 = await FixEntity.getSuggestionsByFixEntityId( - sampleData.fixEntities[1].getId(), - ); - - expect(suggestionsFromFixEntity1).to.be.an('array').with.length(1); - expect(suggestionsFromFixEntity1[0].getId()).to.equal(suggestion.getId()); - - expect(suggestionsFromFixEntity2).to.be.an('array').with.length(1); - expect(suggestionsFromFixEntity2[0].getId()).to.equal(suggestion.getId()); - }); - - it('cascades delete of junction records when fix entity is deleted', async () => { - const fixEntity = sampleData.fixEntities[0]; - const suggestion1 = sampleData.suggestions[0]; - const suggestion2 = sampleData.suggestions[1]; - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - // Create relationships between fix entity and suggestions - await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntity, - [suggestion1, suggestion2], - ); - - // Verify relationships existy - const firstJunctionRecord = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion1.getId(), - fixEntityId: fixEntity.getId(), - }); - const secondJunctionRecord = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion2.getId(), - fixEntityId: fixEntity.getId(), - }); - expect(firstJunctionRecord).to.be.an('array').with.length(1); - expect(secondJunctionRecord).to.be.an('array').with.length(1); - - // Delete the fix entity (this should cascade delete junction records) - await fixEntity.remove(); - - // Verify junction records are deleted - const firstJunctionRecordAfter = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion1.getId(), - fixEntityId: fixEntity.getId(), - }); - const secondJunctionRecordAfter = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion2.getId(), - fixEntityId: fixEntity.getId(), - }); - expect(firstJunctionRecordAfter).to.be.an('array').with.length(0); - expect(secondJunctionRecordAfter).to.be.an('array').with.length(0); - - // Verify suggestions still exist (they should not be deleted) - const suggestion1After = await Suggestion.findById(suggestion1.getId()); - const suggestion2After = await Suggestion.findById(suggestion2.getId()); - expect(suggestion1After).to.not.be.null; - expect(suggestion2After).to.not.be.null; - }); - - it('cascades delete of junction records when suggestion is deleted', async () => { - const suggestion = sampleData.suggestions[2]; - const fixEntity1 = sampleData.fixEntities[1]; - const fixEntity2 = sampleData.fixEntities[2]; - - // Create relationships between suggestion and fix entities using direct junction records - const junctionData = [ - { - suggestionId: suggestion.getId(), - fixEntityId: fixEntity1.getId(), - opportunityId: fixEntity1.getOpportunityId(), - fixEntityCreatedAt: fixEntity1.getExecutedAt() || fixEntity1.getCreatedAt(), - }, - { - suggestionId: suggestion.getId(), - fixEntityId: fixEntity2.getId(), - opportunityId: fixEntity2.getOpportunityId(), - fixEntityCreatedAt: fixEntity2.getExecutedAt() || fixEntity2.getCreatedAt(), - }, - ]; - await FixEntitySuggestion.createMany(junctionData); - - // Verify relationships exist - const firstJunctionRecordBefore = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion.getId(), - fixEntityId: fixEntity1.getId(), - }); - const secondJunctionRecordBefore = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion.getId(), - fixEntityId: fixEntity2.getId(), - }); - expect(firstJunctionRecordBefore).to.be.an('array').with.length(1); - expect(secondJunctionRecordBefore).to.be.an('array').with.length(1); - - // Delete the suggestion (this should cascade delete junction records) - await suggestion.remove(); - - // Verify junction records are deleted - const firstJunctionRecordAfter = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion.getId(), - fixEntityId: fixEntity1.getId(), - }); - const secondJunctionRecordAfter = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion.getId(), - fixEntityId: fixEntity2.getId(), - }); - expect(firstJunctionRecordAfter).to.be.an('array').with.length(0); - expect(secondJunctionRecordAfter).to.be.an('array').with.length(0); - - // Verify fix entities still exist (they should not be deleted) - const fixEntity1After = await FixEntity.findById(fixEntity1.getId()); - const fixEntity2After = await FixEntity.findById(fixEntity2.getId()); - expect(fixEntity1After).to.not.be.null; - expect(fixEntity2After).to.not.be.null; - }); - - it('only deletes junction records for the deleted entity, not others', async () => { - const fixEntity1 = sampleData.fixEntities[3]; - const fixEntity2 = sampleData.fixEntities[4]; - const suggestion1 = sampleData.suggestions[3]; - const suggestion2 = sampleData.suggestions[4]; - const opportunity1 = { - getId: () => fixEntity1.getOpportunityId(), - }; - const opportunity2 = { - getId: () => fixEntity2.getOpportunityId(), - }; - - // Create multiple relationships - await FixEntity.setSuggestionsForFixEntity( - opportunity1.getId(), - fixEntity1, - [suggestion1, suggestion2], - ); - await FixEntity.setSuggestionsForFixEntity( - opportunity2.getId(), - fixEntity2, - [suggestion1], // suggestion1 is related to both fix entities - ); - - // Verify initial state - const firstJunctionRecords = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion1.getId(), - fixEntityId: fixEntity1.getId(), - }); - const secondJunctionRecords = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion1.getId(), - fixEntityId: fixEntity2.getId(), - }); - const thirdJunctionRecords = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion2.getId(), - fixEntityId: fixEntity1.getId(), - }); - expect(firstJunctionRecords).to.be.an('array').with.length(1); - expect(secondJunctionRecords).to.be.an('array').with.length(1); - expect(thirdJunctionRecords).to.be.an('array').with.length(1); - - // Delete fixEntity1 (this should only delete its junction records) - await fixEntity1.remove(); - - // Verify only fixEntity1's junction records are deleted - const firstJunctionRecordsAfter = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion1.getId(), - fixEntityId: fixEntity1.getId(), - }); - const secondJunctionRecordsAfter = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion1.getId(), - fixEntityId: fixEntity2.getId(), - }); - const thirdJunctionRecordsAfter = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion2.getId(), - fixEntityId: fixEntity1.getId(), - }); - - expect(firstJunctionRecordsAfter).to.be.an('array').with.length(0); - expect(secondJunctionRecordsAfter).to.be.an('array').with.length(1); // Should remain unchanged - expect(thirdJunctionRecordsAfter).to.be.an('array').with.length(0); // Only one relationship remains - - // Verify other entities still exist - const fixEntity2After = await FixEntity.findById(fixEntity2.getId()); - const suggestion1After = await Suggestion.findById(suggestion1.getId()); - const suggestion2After = await Suggestion.findById(suggestion2.getId()); - expect(fixEntity2After).to.not.be.null; - expect(suggestion1After).to.not.be.null; - expect(suggestion2After).to.not.be.null; - }); - - it('handles cascading delete when entity has no relationships', async () => { - const fixEntity = sampleData.fixEntities[5]; // Use an entity with no relationships - const suggestion = sampleData.suggestions[5]; // Use an entity with no relationships - - // Verify no relationships exist initially - const junctionRecordsFixEntityBefore = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion.getId(), - fixEntityId: fixEntity.getId(), - }); - expect(junctionRecordsFixEntityBefore).to.be.an('array').with.length(0); - - // Delete entities (should not cause any errors) - await fixEntity.remove(); - await suggestion.remove(); - - // Verify entities are deleted - const fixEntityAfter = await FixEntity.findById(fixEntity.getId()); - const suggestionAfter = await Suggestion.findById(suggestion.getId()); - expect(fixEntityAfter).to.be.null; - expect(suggestionAfter).to.be.null; - }); - - it('gets junction records by opportunity ID and fix entity created date', async () => { - const opportunityId = 'd27f4e5a-850c-441e-9c22-8e5e08b1e687'; - const fixEntityCreatedDate = '2024-01-15'; - - // Create test data with specific opportunity ID and created date - const fixEntity1 = await FixEntity.create({ - opportunityId, - type: 'CONTENT_UPDATE', - status: 'PENDING', - changeDetails: { - description: 'Test fix entity 1', - changes: [{ field: 'title', oldValue: 'Old Title', newValue: 'New Title' }], - }, - executedAt: '2024-01-15T10:30:00.000Z', - }); - - 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' }], - }, - executedAt: '2024-01-15T14:45:00.000Z', - }); - - 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' }], - }, - executedAt: '2024-01-15T16:00:00.000Z', - }); - - 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: fixEntity1.getExecutedAt() || fixEntity1.getCreatedAt(), - }); - - await FixEntitySuggestion.create({ - suggestionId: suggestion2.getId(), - fixEntityId: fixEntity2.getId(), - opportunityId: fixEntity2.getOpportunityId(), - fixEntityCreatedAt: fixEntity2.getExecutedAt() || fixEntity2.getCreatedAt(), - }); - - // 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: fixEntity3.getExecutedAt() || fixEntity3.getCreatedAt(), - }); - - // 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' }], - }, - executedAt: '2024-01-15T23:59:59.999Z', - }); - - 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: fixEntity.getExecutedAt() || fixEntity.getCreatedAt(), - }); - - // 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}` }], - }, - executedAt: '2024-01-15T10:00:00.000Z', - }); - - 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: fixEntity.getExecutedAt() || fixEntity.getCreatedAt(), - }); - - 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); - }); - - it('uses executedAt when present, falls back to createdAt when not', async () => { - const opportunityId = 'd27f4e5a-850c-441e-9c22-8e5e08b1e687'; - - // Create fix entity WITH executedAt - const fixEntityWithExecutedAt = await FixEntity.create({ - opportunityId, - type: 'CONTENT_UPDATE', - status: 'PENDING', - changeDetails: { - description: 'Fix entity with executedAt', - changes: [{ field: 'title', oldValue: 'Old', newValue: 'New' }], - }, - executedAt: '2024-01-15T10:00:00.000Z', - }); - - // Create fix entity WITHOUT executedAt (will use createdAt) - const fixEntityWithoutExecutedAt = await FixEntity.create({ - opportunityId, - type: 'METADATA_UPDATE', - status: 'PENDING', - changeDetails: { - description: 'Fix entity without executedAt', - changes: [{ field: 'description', oldValue: 'Old', newValue: 'New' }], - }, - }); - - // Create suggestions - 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', - }); - - // Set suggestions for both fix entities - const opportunity = { getId: () => opportunityId }; - await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntityWithExecutedAt, - [suggestion1], - ); - await FixEntity.setSuggestionsForFixEntity( - opportunity.getId(), - fixEntityWithoutExecutedAt, - [suggestion2], - ); - - // Get junction records for the fix entity WITH executedAt - const junctionWithExecutedAt = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion1.getId(), - fixEntityId: fixEntityWithExecutedAt.getId(), - }); - - expect(junctionWithExecutedAt).to.be.an('array').with.length(1); - expect(junctionWithExecutedAt[0].getFixEntityCreatedAt()) - .to.equal('2024-01-15T10:00:00.000Z'); - - // Get junction records for the fix entity WITHOUT executedAt - const junctionWithoutExecutedAt = await FixEntitySuggestion.allByIndexKeys({ - suggestionId: suggestion2.getId(), - fixEntityId: fixEntityWithoutExecutedAt.getId(), - }); - - expect(junctionWithoutExecutedAt).to.be.an('array').with.length(1); - // Should use createdAt since executedAt is not present - expect(junctionWithoutExecutedAt[0].getFixEntityCreatedAt()) - .to.equal(fixEntityWithoutExecutedAt.getCreatedAt()); - - // Verify that the two timestamps are different - expect(junctionWithExecutedAt[0].getFixEntityCreatedAt()) - .to.not.equal(junctionWithoutExecutedAt[0].getFixEntityCreatedAt()); - - // Verify that executedAt was used for the first and createdAt for the second - expect(fixEntityWithExecutedAt.getExecutedAt()).to.equal('2024-01-15T10:00:00.000Z'); - expect(fixEntityWithoutExecutedAt.getExecutedAt()).to.be.undefined; - }); -}); 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 deleted file mode 100644 index d27bea1a5..000000000 --- a/packages/spacecat-shared-data-access/test/it/fix-entity/fix-entity.test.js +++ /dev/null @@ -1,412 +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 } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; -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'); - expect(fixEntity.getOpportunityId()).to.be.a('string'); - expect(fixEntity.getStatus()).to.be.a('string'); - expect(fixEntity.getType()).to.be.a('string'); - expect(fixEntity.getChangeDetails()).to.be.an('object'); - expect(fixEntity.getOrigin()).to.be.a('string'); -} - -describe('FixEntity IT', async () => { - let FixEntity; - let Suggestion; - let sampleData; - - 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 () => { - const sampleFixEntity = sampleData.fixEntities[0]; - const fixEntity = await FixEntity.findById(sampleFixEntity.getId()); - - expect(fixEntity).to.be.an('object'); - expect(fixEntity.getOpportunityId()).to.equal(fixEntityFixtures[0].opportunityId); - }); - - it('gets all fix entities for an opportunity', async () => { - const { opportunityId } = fixEntityFixtures[0]; - - const fixEntities = await FixEntity.allByOpportunityId(opportunityId); - - expect(fixEntities).to.be.an('array'); - expect(fixEntities.length).to.be.greaterThan(0); - - fixEntities.forEach((fixEntity) => { - checkFixEntity(fixEntity); - expect(fixEntity.getOpportunityId()).to.equal(opportunityId); - }); - }); - - it('gets all fix entities for an opportunity by status', async () => { - const { opportunityId } = fixEntityFixtures[1]; - - const fixEntities = await FixEntity.allByOpportunityIdAndStatus(opportunityId, 'FAILED'); - - expect(fixEntities).to.be.an('array'); - expect(fixEntities.length).to.be.greaterThan(0); - - fixEntities.forEach((fixEntity) => { - checkFixEntity(fixEntity); - expect(fixEntity.getOpportunityId()).to.equal(opportunityId); - expect(fixEntity.getStatus()).to.equal('FAILED'); - }); - }); - - it('creates a fix entity', async () => { - const data = { - opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', - status: 'PENDING', - type: 'CONTENT_UPDATE', - changeDetails: { - description: 'Fixes a typo in the content', - changes: [{ field: 'title', oldValue: 'Old Title', newValue: 'New Title' }], - }, - }; - - const fixEntity = await FixEntity.create(data); - - checkFixEntity(fixEntity); - - expect(fixEntity.getOpportunityId()).to.equal(data.opportunityId); - expect(fixEntity.getStatus()).to.equal(data.status); - expect(fixEntity.getType()).to.equal(data.type); - }); - - it('updates a fix entity', async () => { - const fixEntity = await FixEntity.findById(sampleData.fixEntities[0].getId()); - - const updates = { - status: 'DEPLOYED', - }; - - fixEntity.setStatus(updates.status); - - await fixEntity.save(); - - const updatedFixEntity = await FixEntity.findById(sampleData.fixEntities[0].getId()); - - checkFixEntity(updatedFixEntity); - - expect(updatedFixEntity.getStatus()).to.equal(updates.status); - }); - - it('removes a fix entity', async () => { - const fixEntity = await FixEntity.findById(sampleData.fixEntities[0].getId()); - - await fixEntity.remove(); - - const notFound = await FixEntity.findById(sampleData.fixEntities[0].getId()); - expect(notFound).to.equal(null); - }); - - describe('origin attribute', () => { - it('creates a fix entity with explicit origin "spacecat"', async () => { - const data = { - opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', - status: 'PENDING', - type: 'CONTENT_UPDATE', - origin: 'spacecat', - changeDetails: { - description: 'Fixes a typo in the content', - changes: [{ field: 'title', oldValue: 'Old Title', newValue: 'New Title' }], - }, - }; - - const fixEntity = await FixEntity.create(data); - - checkFixEntity(fixEntity); - expect(fixEntity.getOrigin()).to.equal('spacecat'); - }); - - it('creates a fix entity with explicit origin "aso"', async () => { - const data = { - opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', - status: 'PENDING', - type: 'CONTENT_UPDATE', - origin: 'aso', - changeDetails: { - description: 'Fixes a typo in the content', - changes: [{ field: 'title', oldValue: 'Old Title', newValue: 'New Title' }], - }, - }; - - const fixEntity = await FixEntity.create(data); - - checkFixEntity(fixEntity); - expect(fixEntity.getOrigin()).to.equal('aso'); - }); - - it('creates a fix entity without origin (defaults to "spacecat")', async () => { - const data = { - opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', - status: 'PENDING', - type: 'CONTENT_UPDATE', - changeDetails: { - description: 'Fixes a typo in the content', - changes: [{ field: 'title', oldValue: 'Old Title', newValue: 'New Title' }], - }, - }; - - const fixEntity = await FixEntity.create(data); - - checkFixEntity(fixEntity); - expect(fixEntity.getOrigin()).to.equal('spacecat'); // default value - }); - - it('rejects invalid origin values', async () => { - const data = { - opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', - status: 'PENDING', - type: 'CONTENT_UPDATE', - origin: 'invalid-origin', - changeDetails: { - description: 'Fixes a typo in the content', - changes: [{ field: 'title', oldValue: 'Old Title', newValue: 'New Title' }], - }, - }; - - await expect(FixEntity.create(data)).to.be.rejected; - }); - - it('updates a fix entity origin', async () => { - const data = { - opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', - status: 'PENDING', - type: 'CONTENT_UPDATE', - origin: 'spacecat', - changeDetails: { - description: 'Fixes a typo in the content', - changes: [{ field: 'title', oldValue: 'Old Title', newValue: 'New Title' }], - }, - }; - - const fixEntity = await FixEntity.create(data); - expect(fixEntity.getOrigin()).to.equal('spacecat'); - - // Update origin - fixEntity.setOrigin('aso'); - await fixEntity.save(); - - // Verify the update persisted - const updatedFixEntity = await FixEntity.findById(fixEntity.getId()); - expect(updatedFixEntity.getOrigin()).to.equal('aso'); - }); - - it('validates existing fix entities have origin attribute', async () => { - const { opportunityId } = fixEntityFixtures[0]; - const fixEntities = await FixEntity.allByOpportunityId(opportunityId); - - fixEntities.forEach((fixEntity) => { - checkFixEntity(fixEntity); - // Should have origin from fixtures or default to 'spacecat' - expect(['spacecat', 'aso']).to.include(fixEntity.getOrigin()); - }); - }); - }); - it('gets suggestions for a fix entity', async () => { - const fixEntity = sampleData.fixEntities[0]; - - // First, set up some suggestions for this fix entity - const suggestionsToSet = [ - sampleData.suggestions[0], - sampleData.suggestions[1], - ]; - - const opportunity = { - getId: () => fixEntity.getOpportunityId(), - }; - - await FixEntity.setSuggestionsForFixEntity(opportunity.getId(), fixEntity, suggestionsToSet); - - // Test the model method - const suggestions = await fixEntity.getSuggestions(); - - expect(suggestions).to.be.an('array').with.length(2); - suggestions.forEach((suggestion) => { - checkSuggestion(suggestion); - expect(suggestionsToSet.map((s) => s.getId())).to.include(suggestion.getId()); - }); - }); - - it('gets all fixes with suggestions by created date', async () => { - // First, create some fix entities with specific created dates - const opportunityId = 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4'; - const fixEntityCreatedDate = new Date().toISOString().split('T')[0]; // Today's date in YYYY-MM-DD format - - // Create fix entities with the same opportunity and created date - const fixEntity1 = await FixEntity.create({ - opportunityId, - status: 'PENDING', - type: 'CONTENT_UPDATE', - changeDetails: { - description: 'Test fix entity 1', - changes: [{ field: 'title', oldValue: 'Old', newValue: 'New' }], - }, - }); - - const fixEntity2 = await FixEntity.create({ - opportunityId, - status: 'PENDING', - type: 'METADATA_UPDATE', - changeDetails: { - description: 'Test fix entity 2', - changes: [{ field: 'description', oldValue: 'Old', newValue: 'New' }], - }, - }); - - // Create suggestions - const suggestion1 = await Suggestion.create({ - opportunityId, - title: 'Test Suggestion 1', - description: 'Description for suggestion 1', - data: { - foo: 'bar-1', - }, - type: 'CODE_CHANGE', - rank: 0, - status: 'NEW', - }); - - const suggestion2 = await Suggestion.create({ - opportunityId, - title: 'Test Suggestion 2', - description: 'Description for suggestion 2', - data: { - foo: 'bar-2', - }, - type: 'CODE_CHANGE', - rank: 1, - status: 'NEW', - }); - - const suggestion3 = await Suggestion.create({ - opportunityId, - title: 'Test Suggestion 3', - description: 'Description for suggestion 3', - data: { - foo: 'bar-3', - }, - type: 'CODE_CHANGE', - rank: 2, - status: 'NEW', - }); - - // Set up relationships between fix entities and suggestions - const opportunity = { getId: () => opportunityId }; - - // Associate suggestion1 and suggestion2 with fixEntity1 - await FixEntity - .setSuggestionsForFixEntity(opportunity.getId(), fixEntity1, [suggestion1, suggestion2]); - - // Associate suggestion3 with fixEntity2 - await FixEntity.setSuggestionsForFixEntity(opportunity.getId(), fixEntity2, [suggestion3]); - - // Test the getAllFixesWithSuggestionByCreatedAt method - const result = await FixEntity.getAllFixesWithSuggestionByCreatedAt( - opportunityId, - fixEntityCreatedDate, - ); - - expect(result).to.be.an('array'); - expect(result.length).to.equal(2); - - // Check the structure of each result - result.forEach((item) => { - expect(item).to.have.property('fixEntity'); - expect(item).to.have.property('suggestions'); - expect(item.suggestions).to.be.an('array'); - - checkFixEntity(item.fixEntity); - expect(item.fixEntity.getOpportunityId()).to.equal(opportunityId); - - item.suggestions.forEach((suggestion) => { - checkSuggestion(suggestion); - expect(suggestion.getOpportunityId()).to.equal(opportunityId); - }); - }); - - // Verify that we have the correct fix entities - const fixEntityIds = result.map((item) => item.fixEntity.getId()); - expect(fixEntityIds).to.include(fixEntity1.getId()); - expect(fixEntityIds).to.include(fixEntity2.getId()); - - // Verify that fixEntity1 has 2 suggestions and fixEntity2 has 1 suggestion - const fixEntity1Result = result.find((item) => item.fixEntity.getId() === fixEntity1.getId()); - const fixEntity2Result = result.find((item) => item.fixEntity.getId() === fixEntity2.getId()); - - expect(fixEntity1Result.suggestions).to.have.length(2); - expect(fixEntity2Result.suggestions).to.have.length(1); - - // Verify the suggestion IDs match - const fixEntity1SuggestionIds = fixEntity1Result.suggestions.map((s) => s.getId()); - expect(fixEntity1SuggestionIds).to.include(suggestion1.getId()); - expect(fixEntity1SuggestionIds).to.include(suggestion2.getId()); - - const fixEntity2SuggestionIds = fixEntity2Result.suggestions.map((s) => s.getId()); - expect(fixEntity2SuggestionIds).to.include(suggestion3.getId()); - }); - - it('returns empty array when no fixes found for given opportunity and date', async () => { - const opportunityId = '00000000-0000-0000-0000-000000000000'; - const fixEntityCreatedDate = new Date().toISOString().split('T')[0]; // Today's date in YYYY-MM-DD format - - const result = await FixEntity.getAllFixesWithSuggestionByCreatedAt( - opportunityId, - fixEntityCreatedDate, - ); - - expect(result).to.be.an('array'); - expect(result.length).to.equal(0); - }); - - it('validates required parameters', async () => { - const today = new Date().toISOString().split('T')[0]; // Today's date in YYYY-MM-DD format - - // Test missing opportunityId - await expect( - FixEntity.getAllFixesWithSuggestionByCreatedAt(null, today), - ).to.be.rejectedWith('opportunityId must be a valid UUID'); - - // Test missing fixEntityCreatedDate - await expect( - FixEntity.getAllFixesWithSuggestionByCreatedAt('aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', null), - ).to.be.rejectedWith('fixEntityCreatedDate is required'); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/fixtures.js b/packages/spacecat-shared-data-access/test/it/fixtures.js index 8cc6368c6..e77b8004e 100644 --- a/packages/spacecat-shared-data-access/test/it/fixtures.js +++ b/packages/spacecat-shared-data-access/test/it/fixtures.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 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 @@ -10,72 +10,149 @@ * governing permissions and limitations under the License. */ -// eslint-disable-next-line import/no-extraneous-dependencies -import { spawn } from 'dynamo-db-local'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; -import { sleep } from '../unit/util.js'; +const filePath = fileURLToPath(import.meta.url); +const directoryPath = path.dirname(filePath); +const REPO_ROOT = path.resolve(directoryPath, '..', '..'); +const COMPOSE_FILE = path.resolve(directoryPath, 'postgrest', 'docker-compose.yml'); +const TENANT_SEED_DIR = path.resolve(directoryPath, 'seed', 'tenants'); +const IT_POSTGREST_PORT = process.env.IT_POSTGREST_PORT || '3300'; +const POSTGREST_URL = `http://127.0.0.1:${IT_POSTGREST_PORT}`; +const DBMATE_URL = 'postgres://postgres:postgres@db:5432/mysticat?sslmode=disable'; -let dynamoDbLocalProcess = null; +const run = (cmd, args, options = {}) => new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: REPO_ROOT, + stdio: ['pipe', 'pipe', 'pipe'], + ...options, + }); + + let stdout = ''; + let stderr = ''; -async function waitForDynamoDBStartup(url, timeout = 20000, interval = 500) { - const startTime = Date.now(); - while (Date.now() - startTime < timeout) { + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + + reject(new Error(`${cmd} ${args.join(' ')} failed with code ${code}\n${stderr || stdout}`)); + }); + + if (options.input) { + child.stdin.write(options.input); + } + child.stdin.end(); +}); + +const runCompose = async (args, options = {}) => run('docker', ['compose', '-f', COMPOSE_FILE, ...args], options); + +const waitForPostgrest = async (timeoutMs = 120_000, intervalMs = 1_000) => { + const start = Date.now(); + while ((Date.now() - start) < timeoutMs) { try { // eslint-disable-next-line no-await-in-loop - const response = await fetch(url); - if (response.status === 400) { + const response = await fetch(`${POSTGREST_URL}/`); + if (response.ok) { return; } - } catch (error) { - console.log('DynamoDB Local not yet started', error.message); + } catch (e) { + // ignore while booting } + // eslint-disable-next-line no-await-in-loop - await sleep(interval); + await new Promise((resolve) => { + setTimeout(resolve, intervalMs); + }); } - throw new Error('DynamoDB Local did not start within the expected time'); -} -/** - * This function is called once before any tests are executed. It is used to start - * any services that are required for the tests, such as a local DynamoDB instance. - * See https://mochajs.org/#global-fixtures - * @return {Promise} - */ -export async function mochaGlobalSetup() { - console.log('mochaGlobalSetup'); - - process.env.AWS_REGION = 'local'; - process.env.AWS_ENDPOINT_URL_DYNAMODB = 'http://127.0.0.1:8000'; - process.env.AWS_DEFAULT_REGION = 'local'; - process.env.AWS_ACCESS_KEY_ID = 'dummy'; - process.env.AWS_SECRET_ACCESS_KEY = 'dummy'; - - dynamoDbLocalProcess = spawn({ - detached: true, - stdio: 'inherit', - port: 8000, - sharedDb: true, - }); + throw new Error(`PostgREST did not become ready at ${POSTGREST_URL} within ${timeoutMs}ms`); +}; - await waitForDynamoDBStartup('http://127.0.0.1:8000'); +const runSql = async (sql, sourceLabel) => { + if (!sql || !sql.trim()) { + return; + } - process.on('SIGINT', () => { - if (dynamoDbLocalProcess) { - dynamoDbLocalProcess.kill(); - } - process.exit(); - }); + try { + await runCompose([ + 'exec', + '-T', + 'db', + 'psql', + '-v', + 'ON_ERROR_STOP=1', + '-U', + 'postgres', + '-d', + 'mysticat', + ], { input: `${sql}\n` }); + } catch (error) { + throw new Error(`Failed running SQL from ${sourceLabel}: ${error.message}`); + } +}; + +const applyMigrations = async () => { + await runCompose([ + 'run', + '--rm', + 'data-service', + 'dbmate', + '-d', + 'db/migrations', + '--url', + DBMATE_URL, + 'up', + ]); +}; + +const seedTenantData = async () => { + const seedFiles = (await fs.readdir(TENANT_SEED_DIR)) + .filter((file) => file.endsWith('.sql')) + .sort(); + + for (const file of seedFiles) { + const absolutePath = path.resolve(TENANT_SEED_DIR, file); + // eslint-disable-next-line no-await-in-loop + const sql = await fs.readFile(absolutePath, 'utf-8'); + const seedSql = [ + 'BEGIN;', + 'SET LOCAL session_replication_role = replica;', + sql, + 'COMMIT;', + ].join('\n'); + // eslint-disable-next-line no-await-in-loop + await runSql(seedSql, absolutePath); + } +}; + +export async function mochaGlobalSetup() { + process.env.POSTGREST_URL = POSTGREST_URL; + process.env.POSTGREST_SCHEMA = 'public'; + process.env.AWS_REGION = process.env.AWS_REGION || 'us-east-1'; + process.env.AWS_XRAY_SDK_ENABLED = 'false'; + + await runCompose(['down', '-v']).catch(() => {}); + await runCompose(['up', '-d', '--wait', 'db']); + await applyMigrations(); + await seedTenantData(); + await runCompose(['up', '-d', '--wait', 'data-service']); + await waitForPostgrest(); } -/** - * This function is called once after all tests are executed. It is used to clean up - * any services that were started in mochaGlobalSetup. - * See: https://mochajs.org/#global-fixtures - * @return {Promise} - */ export async function mochaGlobalTeardown() { - console.log('mochaGlobalTeardown'); - - dynamoDbLocalProcess.kill(); - dynamoDbLocalProcess = null; + await runCompose(['down', '-v']).catch(() => {}); } diff --git a/packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js b/packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js deleted file mode 100644 index 0034efd4f..000000000 --- a/packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { ElectroValidationError } from 'electrodb'; -import ImportJobModel from '../../../src/models/import-job/import-job.model.js'; -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; -import { DataAccessError } from '../../../src/index.js'; - -use(chaiAsPromised); - -function checkImportJob(importJob) { - expect(importJob).to.be.an('object'); - expect(importJob.getBaseURL()).to.be.a('string'); - expect(importJob.getDuration()).to.be.a('number'); - expect(importJob.getFailedCount()).to.be.a('number'); - expect(importJob.getHasCustomHeaders()).to.be.a('boolean'); - expect(importJob.getHasCustomImportJs()).to.be.a('boolean'); - expect(importJob.getHashedApiKey()).to.be.a('string'); - expect(importJob.getImportQueueId()).to.be.a('string'); - expect(importJob.getInitiatedBy()).to.be.an('object'); - expect(importJob.getRedirectCount()).to.be.an('number'); - expect(importJob.getStartedAt()).to.be.a('string'); - expect(importJob.getStatus()).to.be.a('string'); - expect(importJob.getSuccessCount()).to.be.an('number'); - expect(importJob.getUrlCount()).to.be.an('number'); -} - -describe('ImportJob IT', async () => { - let sampleData; - let ImportJob; - let newJobData; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - ImportJob = dataAccess.ImportJob; - - newJobData = { - importQueueId: 'some-queue-id', - hashedApiKey: 'some-hashed-api-key', - baseURL: 'https://example-some.com/cars', - startedAt: '2023-12-15T01:22:05.000Z', - status: 'RUNNING', - initiatedBy: { - apiKeyName: 'K-321', - }, - hasCustomImportJs: false, - hasCustomHeaders: true, - }; - }); - - it('adds a new import job', async () => { - const importJob = await ImportJob.create(newJobData); - - checkImportJob(importJob); - - expect(importJob.getImportQueueId()).to.equal(newJobData.importQueueId); - expect(importJob.getHashedApiKey()).to.equal(newJobData.hashedApiKey); - expect(importJob.getBaseURL()).to.equal(newJobData.baseURL); - expect(importJob.getStartedAt()).to.equal(newJobData.startedAt); - expect(importJob.getStatus()).to.equal(newJobData.status); - expect(importJob.getInitiatedBy()).to.eql(newJobData.initiatedBy); - expect(importJob.getHasCustomImportJs()).to.equal(newJobData.hasCustomImportJs); - expect(importJob.getHasCustomHeaders()).to.equal(newJobData.hasCustomHeaders); - }); - - it('adds a new import job with valid options', async () => { - const options = { - type: 'xwalk', - data: { - siteName: 'xwalk', - assetFolder: 'xwalk', - }, - }; - - let data = { ...newJobData, options }; - let importJob = await ImportJob.create(data); - - checkImportJob(importJob); - expect(importJob.getOptions()).to.equal(data.options); - - data = { ...newJobData, options: { type: 'doc' } }; - importJob = await ImportJob.create(data); - - checkImportJob(importJob); - expect(importJob.getOptions()).to.eql({ type: 'doc' }); - - // Add test for da type - data = { ...newJobData, options: { type: 'da' } }; - importJob = await ImportJob.create(data); - - checkImportJob(importJob); - expect(importJob.getOptions()).to.eql({ type: 'da' }); - - // test to make sure data error is thrown if data is not an object - data = { ...newJobData, options: { data: 'not-an-object' } }; - await ImportJob.create(data).catch((err) => { - expect(err).to.be.instanceOf(DataAccessError); - expect(err.cause).to.be.instanceOf(ElectroValidationError); - expect(err.cause.message).to.contain('Invalid value for data: not-an-object'); - }); - - // test to make sure data is not an empty object - data = { ...newJobData, options: { data: { } } }; - await ImportJob.create(data).catch((err) => { - expect(err).to.be.instanceOf(DataAccessError); - expect(err.cause).to.be.instanceOf(ElectroValidationError); - expect(err.cause.message).to.contain('Invalid value for data'); - }); - }); - - it('throws an error when adding a new import job with invalid options', async () => { - const data = { ...newJobData, options: { type: 'invalid' } }; - - await ImportJob.create(data).catch((err) => { - expect(err).to.be.instanceOf(DataAccessError); - expect(err.cause).to.be.instanceOf(ElectroValidationError); - expect(err.cause.message).to.contain('Invalid value for type: invalid'); - }); - }); - - it('updates an existing import job', async () => { - const sampleImportJob = sampleData.importJobs[0]; - const importJob = await ImportJob.findById(sampleImportJob.getId()); - - const updates = { - status: 'COMPLETE', - endedAt: '2023-11-15T03:49:13.000Z', - successCount: 86, - failedCount: 4, - redirectCount: 10, - urlCount: 100, - duration: 188000, - }; - - await importJob - .setStatus(updates.status) - .setEndedAt(updates.endedAt) - .setSuccessCount(updates.successCount) - .setFailedCount(updates.failedCount) - .setRedirectCount(updates.redirectCount) - .setUrlCount(updates.urlCount) - .setDuration(updates.duration) - .save(); - - const updatedImportJob = await ImportJob.findById(importJob.getId()); - - checkImportJob(updatedImportJob); - - expect(updatedImportJob.getStatus()).to.equal(updates.status); - expect(updatedImportJob.getEndedAt()).to.equal(updates.endedAt); - expect(updatedImportJob.getSuccessCount()).to.equal(updates.successCount); - expect(updatedImportJob.getFailedCount()).to.equal(updates.failedCount); - expect(updatedImportJob.getRedirectCount()).to.equal(updates.redirectCount); - expect(updatedImportJob.getUrlCount()).to.equal(updates.urlCount); - expect(updatedImportJob.getDuration()).to.equal(updates.duration); - }); - - it('finds an import job by its id', async () => { - const sampleImportJob = sampleData.importJobs[0]; - const importJob = await ImportJob.findById(sampleImportJob.getId()); - - checkImportJob(importJob); - expect(importJob.getId()).to.equal(sampleImportJob.getId()); - }); - - it('gets all import jobs by status', async () => { - const importJobs = await ImportJob.allByStatus(ImportJobModel.ImportJobStatus.COMPLETE); - - expect(importJobs).to.be.an('array'); - expect(importJobs.length).to.equal(2); - expect(importJobs[0].getId()).to.equal(sampleData.importJobs[0].getId()); - importJobs.forEach((importJob) => { - checkImportJob(importJob); - expect(importJob.getStatus()).to.equal(ImportJobModel.ImportJobStatus.COMPLETE); - }); - }); - - it('gets all import jobs by date range', async () => { - const importJobs = await ImportJob.allByDateRange( - '2023-11-14T00:00:00.000Z', - '2023-11-16T00:00:00.000Z', - ); - - expect(importJobs).to.be.an('array'); - expect(importJobs.length).to.equal(2); - - importJobs.forEach((importJob) => { - checkImportJob(importJob); - }); - }); - - it('removes an import job', async () => { - const sampleImportJob = sampleData.importJobs[0]; - const importJob = await ImportJob.findById(sampleImportJob.getId()); - - const importUrls = await importJob.getImportUrls(); - - expect(importUrls).to.be.an('array'); - expect(importUrls.length).to.equal(5); - - await importJob.remove(); - - const removedImportJob = await ImportJob.findById(sampleImportJob.getId()); - expect(removedImportJob).to.be.null; - - // todo: verify import urls are removed when base collection is implemented - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js b/packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js deleted file mode 100644 index 2647da6db..000000000 --- a/packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -function checkImportUrl(importUrl) { - expect(importUrl).to.be.an('object'); - expect(importUrl.getRecordExpiresAt()).to.be.a('number'); - expect(importUrl.getImportJobId()).to.be.a('string'); - expect(importUrl.getStatus()).to.be.a('string'); - expect(importUrl.getUrl()).to.be.a('string'); -} - -describe('ImportUrl IT', async () => { - let sampleData; - let ImportUrl; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - ImportUrl = dataAccess.ImportUrl; - }); - - it('adds a new import url', async () => { - const sampleImportJob = sampleData.importJobs[0]; - const data = { - importJobId: sampleImportJob.getId(), - url: 'https://example-some.com/cars', - status: 'RUNNING', - initiatedBy: { - apiKeyName: 'K-321', - imsUserId: 'U-123', - imsOrgId: 'O-123', - }, - }; - - const importUrl = await ImportUrl.create(data); - - checkImportUrl(importUrl); - }); - - it('updates an import url', async () => { - const data = { - url: 'https://example-some.com/cars', - status: 'RUNNING', - file: 'some-file', - reason: 'some-reason', - }; - - const importUrl = await ImportUrl.findById(sampleData.importUrls[0].getId()); - await importUrl - .setUrl(data.url) - .setStatus(data.status) - .setFile(data.file) - .setReason(data.reason) - .save(); - - const updatedImportUrl = await ImportUrl.findById(sampleData.importUrls[0].getId()); - - checkImportUrl(updatedImportUrl); - - expect(updatedImportUrl.getStatus()).to.equal(data.status); - expect(updatedImportUrl.getUrl()).to.equal(data.url); - expect(updatedImportUrl.getFile()).to.equal(data.file); - expect(updatedImportUrl.getReason()).to.equal(data.reason); - }); - - it('it gets all import urls by import job id', async () => { - const importJob = sampleData.importJobs[0]; - const importUrls = await ImportUrl.allByImportJobId(importJob.getId()); - - expect(importUrls).to.be.an('array'); - expect(importUrls.length).to.equal(6); - - importUrls.forEach((importUrl) => { - expect(importUrl.getImportJobId()).to.equal(importJob.getId()); - checkImportUrl(importUrl); - }); - }); - - it('it gets all import urls by job id and status', async () => { - const importJob = sampleData.importJobs[0]; - const importUrls = await ImportUrl.allByImportJobIdAndStatus(importJob.getId(), 'RUNNING'); - - expect(importUrls).to.be.an('array'); - expect(importUrls.length).to.equal(2); - - importUrls.forEach((importUrl) => { - expect(importUrl.getImportJobId()).to.equal(importJob.getId()); - expect(importUrl.getStatus()).to.equal('RUNNING'); - checkImportUrl(importUrl); - }); - }); - - it('finds an import url by its id', async () => { - const sampleImportUrl = sampleData.importUrls[0]; - const importUrl = await ImportUrl.findById(sampleImportUrl.getId()); - - checkImportUrl(importUrl); - expect(importUrl.getId()).to.equal(sampleImportUrl.getId()); - }); - - it('removes an import url', async () => { - const sampleImportUrl = sampleData.importUrls[0]; - const importUrl = await ImportUrl.findById(sampleImportUrl.getId()); - - await importUrl.remove(); - - const removedImportUrl = await ImportUrl.findById(sampleImportUrl.getId()); - expect(removedImportUrl).to.be.null; - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js b/packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js deleted file mode 100644 index 7489650b9..000000000 --- a/packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -function checkKeyEvent(keyEvent) { - expect(keyEvent).to.be.an('object'); - expect(keyEvent.getId()).to.be.a('string'); - expect(keyEvent.getCreatedAt()).to.be.a('string'); - expect(keyEvent.getUpdatedAt()).to.be.a('string'); - expect(keyEvent.getSiteId()).to.be.a('string'); - expect(keyEvent.getName()).to.be.a('string'); - expect(keyEvent.getType()).to.be.a('string'); - expect(keyEvent.getTime()).to.be.a('string'); -} - -describe('KeyEvent IT', async () => { - let sampleData; - let KeyEvent; - let Site; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - KeyEvent = dataAccess.KeyEvent; - Site = dataAccess.Site; - }); - - it('gets all key events for a site', async () => { - const site = sampleData.sites[1]; - - const keyEvents = await KeyEvent.allBySiteId(site.getId()); - - expect(keyEvents).to.be.an('array'); - expect(keyEvents.length).to.equal(10); - - keyEvents.forEach((keyEvent) => { - expect(keyEvent.getSiteId()).to.equal(site.getId()); - checkKeyEvent(keyEvent); - }); - }); - - it('adds a new key event for a site', async () => { - const site = sampleData.sites[1]; - const keyEvent = await KeyEvent.create({ - siteId: site.getId(), - name: 'keyEventName', - type: 'PERFORMANCE', - time: '2024-12-06T08:35:24.125Z', - }); - - checkKeyEvent(keyEvent); - - expect(keyEvent.getSiteId()).to.equal(site.getId()); - - const siteWithKeyEvent = await Site.findById(site.getId()); - - const keyEvents = await siteWithKeyEvent.getKeyEvents(); - expect(keyEvents).to.be.an('array'); - expect(keyEvents.length).to.equal(11); - - const lastKeyEvent = keyEvents[0]; - checkKeyEvent(lastKeyEvent); - expect(lastKeyEvent.getId()).to.equal(keyEvent.getId()); - }); - - it('removes a key event', async () => { - const site = sampleData.sites[1]; - const keyEvents = await site.getKeyEvents(); - const keyEvent = keyEvents[0]; - - await keyEvent.remove(); - - const siteWithKeyEvent = await Site.findById(site.getId()); - - const updatedKeyEvents = await siteWithKeyEvent.getKeyEvents(); - expect(updatedKeyEvents).to.be.an('array'); - expect(updatedKeyEvents.length).to.equal(keyEvents.length - 1); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/latest-audit/latest-audit.test.js b/packages/spacecat-shared-data-access/test/it/latest-audit/latest-audit.test.js deleted file mode 100644 index 54a58365f..000000000 --- a/packages/spacecat-shared-data-access/test/it/latest-audit/latest-audit.test.js +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/util/util.js'; -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -function checkAudit(audit) { - expect(audit).to.be.an('object'); - expect(audit.getId()).to.be.a('string'); - expect(audit.getAuditId()).to.be.a('string'); - expect(audit.getSiteId()).to.be.a('string'); - expect(audit.getAuditType()).to.be.a('string'); - expect(audit.getAuditedAt()).to.be.a('string'); - expect(audit.getAuditResult()).to.be.an('object'); - expect(audit.getScores()).to.be.an('object'); - expect(audit.getFullAuditRef()).to.be.a('string'); - expect(audit.getIsLive()).to.be.a('boolean'); -} - -describe('LatestAudit IT', async () => { - let sampleData; - let LatestAudit; - let Audit; - let dataAccess; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - dataAccess = getDataAccess(); - LatestAudit = dataAccess.LatestAudit; - Audit = dataAccess.Audit; - }); - - it('finds latest audit by id', async () => { - const site = sampleData.sites[1]; - const audits = await site.getLatestAudits(); - - const audit = await LatestAudit.findById( - site.getId(), - audits[0].getAuditType(), - ); - - checkAudit(audit); - expect(audit.getSiteId()).to.equal(site.getId()); - expect(audit.getAuditType()).to.equal(audits[0].getAuditType()); - }); - - it('gets all latest audits', async () => { - const audits = await LatestAudit.all(); - - expect(audits).to.be.an('array'); - // cwv & lhs for 9 sites with audits - expect(audits.length).to.equal(18); - - for (const audit of audits) { - checkAudit(audit); - // eslint-disable-next-line no-await-in-loop - const original = await Audit.findById(audit.getAuditId()); - expect(original).to.not.be.null; - expect( - sanitizeIdAndAuditFields('latestAudit', audit.toJSON()), - ).to.deep.equal( - sanitizeTimestamps(original.toJSON()), - ); - } - }); - - it('gets all latest audits for a site', async () => { - const site = sampleData.sites[1]; - - const audits = await LatestAudit.allBySiteId(site.getId()); - - expect(audits).to.be.an('array'); - // cwv & lhs - expect(audits.length).to.equal(2); - expect(audits[0].getAuditedAt()).to.equal(sampleData.audits[4].getAuditedAt()); - expect(audits[1].getAuditedAt()).to.equal(sampleData.audits[9].getAuditedAt()); - - audits.forEach((audit) => { - expect(audit.getSiteId()).to.equal(site.getId()); - checkAudit(audit); - }); - }); - - it('gets all latest audits of a type', async () => { - const audits = await LatestAudit.allByAuditType('cwv'); - - expect(audits).to.be.an('array'); - expect(audits.length).to.equal(9); - audits.forEach((audit) => { - expect(audit.getAuditType()).to.equal('cwv'); - checkAudit(audit); - }); - }); - - it('gets latest audits of type for a site', async () => { - const auditType = 'lhs-mobile'; - const site = sampleData.sites[1]; - - const audits = await LatestAudit.allBySiteIdAndAuditType(site.getId(), auditType); - - expect(audits).to.be.an('array'); - expect(audits.length).to.equal(1); - - audits.forEach((audit) => { - expect(audit.getSiteId()).to.equal(site.getId()); - expect(audit.getAuditType()).to.equal(auditType); - checkAudit(audit); - }); - }); - - it('gets latest audit of type lhs-mobile for a site', async () => { - const auditType = 'lhs-mobile'; - const site = sampleData.sites[1]; - const audits = await site.getLatestAudits(); - const audit = await site.getLatestAuditByAuditType(auditType); - - checkAudit(audit); - - expect(audit.getSiteId()).to.equal(site.getId()); - expect(audit.getAuditType()).to.equal(auditType); - expect(audit.getAuditedAt()).to.equal(audits[0].getAuditedAt()); - }); - - it('returns null for non-existing audit', async () => { - const site = sampleData.sites[1]; - const audit = await site.getLatestAuditByAuditType('non-existing-type'); - - expect(audit).to.be.null; - }); - - it('updates a latest audit upon audit creation', async () => { - const auditType = 'lhs-mobile'; - const site = sampleData.sites[1]; - const previousLatestAudit = await site.getLatestAuditByAuditType(auditType); - const audit = await Audit.create({ - siteId: site.getId(), - isLive: true, - auditedAt: '2025-01-06T10:11:51.833Z', - auditType, - auditResult: { - scores: { - performance: 0.4, - seo: 0.47, - accessibility: 0.27, - 'best-practices': 0.55, - }, - }, - fullAuditRef: 'https://example.com/audit', - }); - checkAudit(audit); - const updatedSite = await dataAccess.Site.findById(site.getId()); - const latestAudit = await updatedSite.getLatestAuditByAuditType(auditType); - checkAudit(latestAudit); - expect(latestAudit.getSiteId()).to.equal(site.getId()); - expect(latestAudit.getAuditType()).to.equal(auditType); - expect(latestAudit.getAuditedAt()).to.equal(audit.getAuditedAt()); - expect(latestAudit.getAuditedAt()).to.not.equal(previousLatestAudit.getAuditedAt()); - expect(latestAudit.getUpdatedAt()).to.not.equal(previousLatestAudit.getUpdatedAt()); - }); - - it('creates a latest audit upon audit creation', async () => { - const auditType = 'broken-backlinks'; - const site = sampleData.sites[0]; - const previousLatestAudit = await site.getLatestAuditByAuditType(auditType); - - const audit = await Audit.create({ - siteId: site.getId(), - isLive: true, - auditedAt: '2025-01-06T10:11:51.833Z', - auditType, - auditResult: { - scores: { - performance: 0.4, - seo: 0.47, - accessibility: 0.27, - 'best-practices': 0.55, - }, - }, - fullAuditRef: 'https://example.com/audit', - }); - checkAudit(audit); - const updatedSite = await dataAccess.Site.findById(site.getId()); - const latestAudit = await updatedSite.getLatestAuditByAuditType(auditType); - checkAudit(latestAudit); - expect(previousLatestAudit).to.be.null; - expect(latestAudit.getSiteId()).to.equal(site.getId()); - expect(latestAudit.getAuditType()).to.equal(auditType); - expect(latestAudit.getAuditedAt()).to.equal(audit.getAuditedAt()); - }); -}); 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 deleted file mode 100644 index a5a58f42f..000000000 --- a/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js +++ /dev/null @@ -1,613 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { isIsoDate, isValidUUID } from '@adobe/spacecat-shared-utils'; - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; - -import { ValidationError } from '../../../src/index.js'; - -import fixtures from '../../fixtures/index.fixtures.js'; -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; -import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/util/util.js'; - -use(chaiAsPromised); -use(sinonChai); - -describe('Opportunity IT', async () => { - const { siteId } = fixtures.sites[0]; - - let sampleData; - let mockLogger; - - let Opportunity; - let Suggestion; - let FixEntity; - let FixEntitySuggestion; - - before(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); - Opportunity = dataAccess.Opportunity; - Suggestion = dataAccess.Suggestion; - FixEntity = dataAccess.FixEntity; - FixEntitySuggestion = dataAccess.FixEntitySuggestion; - }); - - afterEach(() => { - sinon.restore(); - }); - - it('finds one opportunity by id', async () => { - const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - - expect(opportunity).to.be.an('object'); - expect( - sanitizeTimestamps(opportunity.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleData.opportunities[0].toJSON()), - ); - - const suggestions = await opportunity.getSuggestions(); - expect(suggestions).to.be.an('array').with.length(3); - - const parentOpportunity = await suggestions[0].getOpportunity(); - expect(parentOpportunity).to.be.an('object'); - expect( - sanitizeTimestamps(opportunity.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleData.opportunities[0].toJSON()), - ); - }); - - it('finds all opportunities by siteId and status', async () => { - const opportunities = await Opportunity.allBySiteIdAndStatus(siteId, 'NEW'); - - expect(opportunities).to.be.an('array').with.length(2); - }); - - it('partially updates one opportunity by id', async () => { - // retrieve the opportunity by ID - const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - expect(opportunity).to.be.an('object'); - expect( - sanitizeTimestamps(opportunity.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleData.opportunities[0].toJSON()), - ); - - // apply updates - const updates = { - runbook: 'https://example-updated.com', - status: 'IN_PROGRESS', - }; - - opportunity - .setRunbook(updates.runbook) - .setStatus(updates.status); - - // opportunity.setAuditId('invalid-audit-id'); - - await opportunity.save(); - - expect(opportunity.getRunbook()).to.equal(updates.runbook); - expect(opportunity.getStatus()).to.equal(updates.status); - - const updated = sanitizeTimestamps(opportunity.toJSON()); - delete updated.runbook; - delete updated.status; - - const original = sanitizeTimestamps(sampleData.opportunities[0].toJSON()); - delete original.runbook; - delete original.status; - - expect(updated).to.eql(original); - - const storedOpportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - expect(storedOpportunity.getRunbook()).to.equal(updates.runbook); - expect(storedOpportunity.getStatus()).to.equal(updates.status); - - const storedWithoutUpdatedAt = { ...storedOpportunity.toJSON() }; - const inMemoryWithoutUpdatedAt = { ...opportunity.toJSON() }; - delete storedWithoutUpdatedAt.updatedAt; - delete inMemoryWithoutUpdatedAt.updatedAt; - - expect(storedWithoutUpdatedAt).to.eql(inMemoryWithoutUpdatedAt); - }); - - it('finds all opportunities by siteId', async () => { - const opportunities = await Opportunity.allBySiteId(siteId); - - expect(opportunities).to.be.an('array').with.length(3); - }); - - it('creates a new opportunity', async () => { - const data = { - siteId, - auditId: crypto.randomUUID(), - title: 'New Opportunity', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - guidance: { foo: 'bar' }, - data: { brokenLinks: ['https://example.com'] }, - updatedBy: 'system', - }; - - const opportunity = await Opportunity.create(data); - - expect(opportunity).to.be.an('object'); - - expect(isValidUUID(opportunity.getId())).to.be.true; - expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; - expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; - - const record = opportunity.toJSON(); - delete record.opportunityId; - delete record.createdAt; - delete record.updatedAt; - expect(record).to.eql(data); - }); - - it('creates a new opportunity without auditId', async () => { - const data = { - siteId, - title: 'New Opportunity', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - guidance: { foo: 'bar' }, - data: { brokenLinks: ['https://example.com'] }, - updatedBy: 'system', - }; - - const opportunity = await Opportunity.create(data); - - expect(opportunity).to.be.an('object'); - - expect(isValidUUID(opportunity.getId())).to.be.true; - expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; - expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; - - const record = opportunity.toJSON(); - delete record.opportunityId; - delete record.createdAt; - delete record.updatedAt; - expect(record).to.eql(data); - - expect(opportunity.getAuditId()).to.be.undefined; - await expect(opportunity.getAudit()).to.eventually.be.equal(null); - }); - - it('removes an opportunity', async () => { - const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - const suggestions = await opportunity.getSuggestions(); - - expect(suggestions).to.be.an('array').with.length(3); - - await opportunity.remove(); - - const notFound = await Opportunity.findById(sampleData.opportunities[0].getId()); - await expect(notFound).to.be.null; - - // make sure dependent suggestions are removed as well - await Promise.all(suggestions.map(async (suggestion) => { - const notFoundSuggestion = await Suggestion.findById(suggestion.getId()); - await expect(notFoundSuggestion).to.be.null; - })); - }); - - it('throws when removing a dependent fails', async () => { /* eslint-disable no-underscore-dangle */ - const opportunity = await Opportunity.findById(sampleData.opportunities[1].getId()); - const suggestions = await opportunity.getSuggestions(); - - expect(suggestions).to.be.an('array').with.length(3); - - // make one suggestion fail to remove - suggestions[0]._remove = sinon.stub().rejects(new Error('Failed to remove suggestion')); - - opportunity.getSuggestions = sinon.stub().resolves(suggestions); - - await expect(opportunity.remove()).to.be.rejectedWith(`Failed to remove entity opportunity with ID ${opportunity.getId()}`); - expect(suggestions[0]._remove).to.have.been.calledOnce; - expect(mockLogger.error).to.have.been.calledWith(`Failed to remove dependent entity suggestion with ID ${suggestions[0].getId()}`); - - // make sure the opportunity is still there - const stillThere = await Opportunity.findById(sampleData.opportunities[1].getId()); - 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()); - }); - - it('creates many opportunities', async () => { - const data = [ - { - siteId, - auditId: crypto.randomUUID(), - title: 'New Opportunity 1', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - data: { brokenLinks: ['https://example.com'] }, - updatedBy: 'system', - }, - { - siteId, - auditId: crypto.randomUUID(), - title: 'New Opportunity 2', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-internal-links', - origin: 'AI', - status: 'NEW', - data: { brokenInternalLinks: ['https://example.com'] }, - updatedBy: 'system', - }, - ]; - - const opportunities = await Opportunity.createMany(data); - - expect(opportunities).to.be.an('object'); - expect(opportunities.createdItems).to.be.an('array').with.length(2); - expect(opportunities.errorItems).to.be.an('array').with.length(0); - - opportunities.createdItems.forEach((opportunity, index) => { - expect(opportunity).to.be.an('object'); - - expect(isValidUUID(opportunity.getId())).to.be.true; - expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; - expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; - - expect( - sanitizeIdAndAuditFields('Opportunity', opportunity.toJSON()), - ).to.eql( - sanitizeTimestamps(data[index]), - ); - }); - }); - - it('fails to create many opportunities with invalid data', async () => { - const data = [ - { - siteId, - auditId: crypto.randomUUID(), - title: 'New Opportunity 1', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - data: { brokenLinks: ['https://example.com'] }, - updatedBy: 'system', - }, - { - siteId, - auditId: crypto.randomUUID(), - title: 'New Opportunity 2', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-internal-links', - origin: 'AI', - status: 'NEW', - data: { brokenInternalLinks: ['https://example.com'] }, - updatedBy: 'system', - }, - { - siteId, - auditId: crypto.randomUUID(), - title: 'New Opportunity 3', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-internal-links', - origin: 'AI', - status: 'NEW', - data: { brokenInternalLinks: ['https://example.com'] }, - updatedBy: 'system', - }, - ]; - - data[2].title = null; - - const result = await Opportunity.createMany(data); - - expect(result).to.be.an('object'); - expect(result).to.have.property('createdItems'); - expect(result).to.have.property('errorItems'); - - expect(result.createdItems).to.be.an('array').with.length(2); - expect(result.errorItems).to.be.an('array').with.length(1); - expect(result.errorItems[0].item).to.eql(data[2]); - expect(result.errorItems[0].error).to.be.an.instanceOf(ValidationError); - - const [opportunity1, opportunity2] = result.createdItems; - - const record1 = opportunity1.toJSON(); - delete record1.opportunityId; - delete record1.createdAt; - delete record1.updatedAt; - - const record2 = opportunity2.toJSON(); - delete record2.opportunityId; - delete record2.createdAt; - delete record2.updatedAt; - - expect(record1).to.eql(data[0]); - expect(record2).to.eql(data[1]); - }); - - describe('addFixEntities', () => { - it('creates fix entities with valid suggestions', async () => { - const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); - const suggestions = await opportunity.getSuggestions(); - - expect(suggestions).to.be.an('array').with.length(3); - - const fixEntityData = [ - { - type: 'CODE_CHANGE', - changeDetails: { - file: 'test1.js', - changes: 'some changes', - }, - suggestions: [suggestions[0].getId(), suggestions[1].getId()], - }, - { - type: 'CONTENT_UPDATE', - changeDetails: { - file: 'test2.md', - changes: 'content changes', - }, - suggestions: [suggestions[2].getId()], - }, - ]; - - const result = await opportunity.addFixEntities(fixEntityData); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(2); - expect(result.errorItems).to.be.an('array').with.length(0); - - // Verify fix entities were created - const fixEntity1 = result.createdItems[0]; - const fixEntity2 = result.createdItems[1]; - - expect(isValidUUID(fixEntity1.getId())).to.be.true; - expect(isValidUUID(fixEntity2.getId())).to.be.true; - expect(fixEntity1.getType()).to.equal('CODE_CHANGE'); - expect(fixEntity2.getType()).to.equal('CONTENT_UPDATE'); - expect(fixEntity1.getStatus()).to.equal('PENDING'); - expect(fixEntity2.getStatus()).to.equal('PENDING'); - - // Verify junction records were created - const junctionRecords1 = await FixEntitySuggestion.allByFixEntityId(fixEntity1.getId()); - const junctionRecords2 = await FixEntitySuggestion.allByFixEntityId(fixEntity2.getId()); - - expect(junctionRecords1).to.be.an('array').with.length(2); - expect(junctionRecords2).to.be.an('array').with.length(1); - - // Verify the fix entities can be retrieved through their suggestions - const suggestionsForFixEntity1 = await FixEntity.getSuggestionsByFixEntityId( - fixEntity1.getId(), - ); - expect(suggestionsForFixEntity1).to.be.an('array').with.length(2); - expect(suggestionsForFixEntity1.map((s) => s.getId())).to.have.members([ - suggestions[0].getId(), - suggestions[1].getId(), - ]); - }); - - it('handles invalid fixEntities without suggestions property', async () => { - const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); - - const fixEntityData = [ - { - type: 'CODE_CHANGE', - changeDetails: { - file: 'test.js', - }, - // Missing suggestions property - }, - ]; - - const result = await opportunity.addFixEntities(fixEntityData); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(0); - expect(result.errorItems).to.be.an('array').with.length(1); - expect(result.errorItems[0].error.message).to.equal('fixEntity must have a suggestions property'); - }); - - it('handles fixEntities with empty suggestions array', async () => { - const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); - - const fixEntityData = [ - { - type: 'CODE_CHANGE', - changeDetails: { - file: 'test.js', - }, - suggestions: [], - }, - ]; - - const result = await opportunity.addFixEntities(fixEntityData); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(0); - expect(result.errorItems).to.be.an('array').with.length(1); - expect(result.errorItems[0].error.message).to.equal('fixEntity.suggestions cannot be empty'); - }); - - it('handles fixEntities with invalid suggestion IDs', async () => { - const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); - - const fixEntityData = [ - { - type: 'CODE_CHANGE', - changeDetails: { - file: 'test.js', - }, - suggestions: ['invalid-suggestion-id', 'another-invalid-id'], - }, - ]; - - const result = await opportunity.addFixEntities(fixEntityData); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(0); - expect(result.errorItems).to.be.an('array').with.length(1); - expect(result.errorItems[0].error.message).to.include('Invalid suggestion IDs'); - }); - - it('processes mixed valid and invalid fixEntities', async () => { - const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); - const suggestions = await opportunity.getSuggestions(); - - const fixEntityData = [ - { - type: 'CODE_CHANGE', - changeDetails: { - file: 'valid.js', - }, - suggestions: [suggestions[0].getId()], - }, - { - type: 'CONTENT_UPDATE', - changeDetails: { - file: 'no-suggestions.md', - }, - // Missing suggestions - }, - { - type: 'REDIRECT_UPDATE', - changeDetails: { - from: '/old', - to: '/new', - }, - suggestions: [], // Empty array - }, - { - type: 'METADATA_UPDATE', - changeDetails: { - title: 'Updated Title', - }, - suggestions: ['invalid-id'], // Invalid suggestion ID - }, - { - type: 'AI_INSIGHTS', - changeDetails: { - insights: 'Some insights', - }, - suggestions: [suggestions[1].getId(), suggestions[2].getId()], - }, - ]; - - const result = await opportunity.addFixEntities(fixEntityData); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(2); - expect(result.errorItems).to.be.an('array').with.length(3); - - // Verify the valid ones were created - expect(result.createdItems[0].getType()).to.equal('CODE_CHANGE'); - expect(result.createdItems[1].getType()).to.equal('AI_INSIGHTS'); - - // Verify error messages - expect(result.errorItems[0].error.message).to.equal('fixEntity must have a suggestions property'); - expect(result.errorItems[1].error.message).to.equal('fixEntity.suggestions cannot be empty'); - expect(result.errorItems[2].error.message).to.include('Invalid suggestion IDs'); - }); - - it('handles fixEntity creation errors from validation', async () => { - const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); - const suggestions = await opportunity.getSuggestions(); - - const fixEntityData = [ - { - type: 'INVALID_TYPE', // Invalid type - changeDetails: { - file: 'test.js', - }, - suggestions: [suggestions[0].getId()], - }, - ]; - - const result = await opportunity.addFixEntities(fixEntityData); - - expect(result).to.be.an('object'); - expect(result.createdItems).to.be.an('array').with.length(0); - expect(result.errorItems).to.be.an('array').with.length(1); - expect(result.errorItems[0].error).to.be.an.instanceOf(ValidationError); - }); - - it('creates fix entities across multiple opportunities', async () => { - const opportunity1 = await Opportunity.findById(sampleData.opportunities[2].getId()); - const opportunity2 = await Opportunity.findById(sampleData.opportunities[1].getId()); - - const suggestions1 = await opportunity1.getSuggestions(); - const suggestions2 = await opportunity2.getSuggestions(); - - const fixEntityData1 = [ - { - type: 'CODE_CHANGE', - changeDetails: { file: 'test1.js' }, - suggestions: [suggestions1[0].getId()], - }, - ]; - - const fixEntityData2 = [ - { - type: 'CONTENT_UPDATE', - changeDetails: { file: 'test2.md' }, - suggestions: [suggestions2[0].getId()], - }, - ]; - - const result1 = await opportunity1.addFixEntities(fixEntityData1); - const result2 = await opportunity2.addFixEntities(fixEntityData2); - - expect(result1.createdItems).to.have.length(1); - expect(result2.createdItems).to.have.length(1); - - // Verify they belong to different opportunities - expect(result1.createdItems[0].getOpportunityId()).to.equal(opportunity1.getId()); - expect(result2.createdItems[0].getOpportunityId()).to.equal(opportunity2.getId()); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/organization/organization.test.js b/packages/spacecat-shared-data-access/test/it/organization/organization.test.js deleted file mode 100644 index 02904c472..000000000 --- a/packages/spacecat-shared-data-access/test/it/organization/organization.test.js +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/util/util.js'; -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -describe('Organization IT', async () => { - let sampleData; - let Organization; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - Organization = dataAccess.Organization; - }); - - it('gets all organizations', async () => { - const organizations = await Organization.all(); - organizations.reverse(); // sort key is descending by default - - expect(organizations).to.be.an('array'); - expect(organizations.length).to.equal(sampleData.organizations.length); - for (let i = 0; i < organizations.length; i += 1) { - const org = sanitizeTimestamps(organizations[i].toJSON()); - const sampleOrg = sanitizeTimestamps(sampleData.organizations[i].toJSON()); - - const expectedConfig = { - ...sampleOrg.config, - }; - const actualConfig = { - ...org.config.state, - }; - delete sampleOrg.config; - delete org.config; - expect(org).to.eql(sampleOrg); - expect(actualConfig).to.eql(expectedConfig); - } - }); - - it('gets an organization by id', async () => { - const sampleOrganization = sampleData.organizations[0]; - const organization = await Organization.findById(sampleOrganization.getId()); - - delete sampleOrganization.record.config; - delete organization.record.config; - - expect(organization).to.be.an('object'); - expect( - sanitizeTimestamps(organization.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleOrganization.toJSON()), - ); - }); - - it('gets an organization by IMS org id', async () => { - const sampleOrganization = sampleData.organizations[0]; - const organization = await Organization.findByImsOrgId(sampleOrganization.getImsOrgId()); - - delete sampleOrganization.record.config; - delete organization.record.config; - - expect(organization).to.be.an('object'); - expect( - sanitizeTimestamps(organization.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleOrganization.toJSON()), - ); - }); - - it('adds a new organization', async () => { - const data = { - name: 'New Organization', - imsOrgId: '1234567893ABCDEF12345678@AdobeOrg', - config: { - some: 'config', - }, - fulfillableItems: { - some: 'items', - }, - updatedBy: 'system', - }; - - const organization = await Organization.create(data); - - delete data.config; - delete organization.record.config; - - expect(organization).to.be.an('object'); - - expect( - sanitizeIdAndAuditFields('Organization', organization.toJSON()), - ).to.eql(data); - }); - - it('updates an organization', async () => { - const organization = await Organization.findById(sampleData.organizations[0].getId()); - - const data = { - name: 'Updated Organization', - imsOrgId: '1234567894ABCDEF12345678@AdobeOrg', - config: { - some: 'updated', - }, - fulfillableItems: { - some: 'updated', - }, - }; - - const expectedOrganization = { - ...organization.toJSON(), - ...data, - }; - - organization.setName(data.name); - organization.setImsOrgId(data.imsOrgId); - organization.setConfig(data.config); - organization.setFulfillableItems(data.fulfillableItems); - - await organization.save(); - - const updatedOrganization = await Organization.findById(organization.getId()); - - delete updatedOrganization.record.config; - delete expectedOrganization.config; - - expect(updatedOrganization.getId()).to.equal(organization.getId()); - expect(updatedOrganization.record.createdAt).to.equal(organization.record.createdAt); - expect(updatedOrganization.record.updatedAt).to.not.equal(organization.record.updatedAt); - expect( - sanitizeIdAndAuditFields('Organization', updatedOrganization.toJSON()), - ).to.eql( - sanitizeIdAndAuditFields('Organization', expectedOrganization), - ); - }); - - it('updates an organization with a new config', async () => { - const organization = await Organization.findById(sampleData.organizations[2].getId()); - const data = { config: { some: 'updated' } }; - - organization.setConfig(data.config); - - const updatedOrganization = await organization.save(); - - expect(updatedOrganization.getConfig().state).to.eql(data.config); - }); - - it('removes an organization', async () => { - const organization = await Organization.findById(sampleData.organizations[0].getId()); - - await organization.remove(); - - const notFound = await Organization.findById(sampleData.organizations[0].getId()); - expect(notFound).to.be.null; - - // todo: add test for removing an organization with associated sites once - // that functionality is implemented - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/page-citability/page-citability.test.js b/packages/spacecat-shared-data-access/test/it/page-citability/page-citability.test.js deleted file mode 100644 index f673f86ab..000000000 --- a/packages/spacecat-shared-data-access/test/it/page-citability/page-citability.test.js +++ /dev/null @@ -1,176 +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 } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; - -use(chaiAsPromised); - -function checkPageCitability(pc) { - expect(pc).to.be.an('object'); - expect(pc.getUrl()).to.be.a('string'); - expect(pc.getSiteId()).to.be.a('string'); - expect(pc.getCreatedAt()).to.be.a('string'); - expect(pc.getUpdatedAt()).to.be.a('string'); - - // Optional numeric fields - can be number or undefined - const citabilityScore = pc.getCitabilityScore(); - if (citabilityScore !== undefined) { - expect(citabilityScore).to.be.a('number'); - } - - const contentRatio = pc.getContentRatio(); - if (contentRatio !== undefined) { - expect(contentRatio).to.be.a('number'); - } - - const wordDifference = pc.getWordDifference(); - if (wordDifference !== undefined) { - expect(wordDifference).to.be.a('number'); - } - - const botWords = pc.getBotWords(); - if (botWords !== undefined) { - expect(botWords).to.be.a('number'); - } - - const normalWords = pc.getNormalWords(); - if (normalWords !== undefined) { - expect(normalWords).to.be.a('number'); - } -} - -describe('PageCitability IT', async () => { - let PageCitability; - - before(async function () { - this.timeout(10000); - const dataAccess = getDataAccess(); - PageCitability = dataAccess.PageCitability; - }); - - it('adds a new page readability record', async () => { - const data = { - url: 'https://www.example.com/test-page', - siteId: '1c86ba81-f3cc-48d8-8b06-1f9ac958e72d', - citabilityScore: 0.85, - contentRatio: 1.25, - wordDifference: 150, - botWords: 500, - normalWords: 650, - }; - const pc = await PageCitability.create(data); - - checkPageCitability(pc); - - expect(pc.getUrl()).to.equal(data.url); - expect(pc.getSiteId()).to.equal(data.siteId); - expect(pc.getCitabilityScore()).to.equal(data.citabilityScore); - expect(pc.getContentRatio()).to.equal(data.contentRatio); - expect(pc.getWordDifference()).to.equal(data.wordDifference); - expect(pc.getBotWords()).to.equal(data.botWords); - expect(pc.getNormalWords()).to.equal(data.normalWords); - }); - - it('finds page readability by URL', async () => { - const testUrl = 'https://www.example.com/findable-page'; - const data = { - url: testUrl, - siteId: '1c86ba81-f3cc-48d8-8b06-1f9ac958e72d', - citabilityScore: 0.75, - contentRatio: 1.15, - wordDifference: 100, - botWords: 400, - normalWords: 500, - }; - - await PageCitability.create(data); - const pc = await PageCitability.findByUrl(testUrl); - - checkPageCitability(pc); - expect(pc.getUrl()).to.equal(testUrl); - }); - - it('returns null when page readability is not found by URL', async () => { - const pc = await PageCitability.findByUrl('https://no-such-page.example.com'); - expect(pc).to.be.null; - }); - - it('updates a page readability record', async () => { - const testUrl = 'https://www.example.com/updatable-page'; - const data = { - url: testUrl, - siteId: '1c86ba81-f3cc-48d8-8b06-1f9ac958e72d', - citabilityScore: 0.60, - contentRatio: 1.05, - wordDifference: 50, - botWords: 300, - normalWords: 350, - }; - - await PageCitability.create(data); - const pc = await PageCitability.findByUrl(testUrl); - - const updates = { - citabilityScore: 0.90, - contentRatio: 1.40, - wordDifference: 200, - botWords: 600, - normalWords: 800, - updatedBy: 'test-user', - }; - - pc.setCitabilityScore(updates.citabilityScore); - pc.setContentRatio(updates.contentRatio); - pc.setWordDifference(updates.wordDifference); - pc.setBotWords(updates.botWords); - pc.setNormalWords(updates.normalWords); - pc.setUpdatedBy(updates.updatedBy); - - await pc.save(); - - checkPageCitability(pc); - - expect(pc.getCitabilityScore()).to.equal(updates.citabilityScore); - expect(pc.getContentRatio()).to.equal(updates.contentRatio); - expect(pc.getWordDifference()).to.equal(updates.wordDifference); - expect(pc.getBotWords()).to.equal(updates.botWords); - expect(pc.getNormalWords()).to.equal(updates.normalWords); - expect(pc.getUpdatedBy()).to.equal(updates.updatedBy); - }); - - it('handles missing stats gracefully', async () => { - const data = { - url: 'https://www.example.com/partial-stats', - siteId: '1c86ba81-f3cc-48d8-8b06-1f9ac958e72d', - citabilityScore: 0.75, - // contentRatio intentionally missing - // wordDifference intentionally missing - botWords: 300, - // normalWords intentionally missing - }; - - const pc = await PageCitability.create(data); - - checkPageCitability(pc); - - expect(pc.getCitabilityScore()).to.equal(0.75); - expect(pc.getContentRatio()).to.be.undefined; - expect(pc.getWordDifference()).to.be.undefined; - expect(pc.getBotWords()).to.equal(300); - expect(pc.getNormalWords()).to.be.undefined; - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/page-intent/page-intent.test.js b/packages/spacecat-shared-data-access/test/it/page-intent/page-intent.test.js deleted file mode 100644 index 655e6309c..000000000 --- a/packages/spacecat-shared-data-access/test/it/page-intent/page-intent.test.js +++ /dev/null @@ -1,111 +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 } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; -import { sanitizeTimestamps } from '../../../src/util/util.js'; - -use(chaiAsPromised); - -function checkPageIntent(pi) { - expect(pi).to.be.an('object'); - expect(pi.getUrl()).to.be.a('string'); - expect(pi.getSiteId()).to.be.a('string'); - expect(pi.getPageIntent()).to.be.a('string'); - expect(pi.getTopic()).to.be.a('string'); - expect(pi.getCreatedAt()).to.be.a('string'); - expect(pi.getUpdatedAt()).to.be.a('string'); -} - -describe('PageIntent IT', async () => { - let sampleData; - let PageIntent; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - PageIntent = dataAccess.PageIntent; - }); - - it('finds one page intent by URL', async () => { - const sample = sampleData.pageIntents[3]; - - const pi = await PageIntent.findByUrl(sample.getUrl()); - - checkPageIntent(pi); - - expect( - sanitizeTimestamps(pi.toJSON()), - ).to.eql( - sanitizeTimestamps(sample.toJSON()), - ); - }); - - it('returns null when page intent is not found by URL', async () => { - const pi = await PageIntent.findByUrl('https://no-such-page.example.com'); - - expect(pi).to.be.null; - }); - - it('adds a new page intent', async () => { - const data = { - url: 'https://www.example.com/new-page', - siteId: '1c86ba81-f3cc-48d8-8b06-1f9ac958e72d', - pageIntent: 'INFORMATIONAL', - topic: 'example-topic', - }; - const pi = await PageIntent.create(data); - - checkPageIntent(pi); - - expect(pi.getUrl()).to.equal(data.url); - expect(pi.getSiteId()).to.equal(data.siteId); - expect(pi.getPageIntent()).to.equal(data.pageIntent); - expect(pi.getTopic()).to.equal(data.topic); - }); - - it('updates a page intent', async () => { - const sample = sampleData.pageIntents[0]; - const updates = { - url: 'https://www.updated.com/page', - siteId: '45508663-a89b-44ea-9a89-a216f8086212', - pageIntent: 'TRANSACTIONAL', - topic: 'updated-topic', - updatedBy: 'test-user', - }; - - const pi = await PageIntent.findByUrl(sample.getUrl()); - - pi.setUrl(updates.url); - pi.setSiteId(updates.siteId); - pi.setPageIntent(updates.pageIntent); - pi.setTopic(updates.topic); - pi.setUpdatedBy(updates.updatedBy); - - await pi.save(); - - checkPageIntent(pi); - - expect(pi.getUrl()).to.equal(updates.url); - expect(pi.getSiteId()).to.equal(updates.siteId); - expect(pi.getPageIntent()).to.equal(updates.pageIntent); - expect(pi.getTopic()).to.equal(updates.topic); - expect(pi.getUpdatedBy()).to.equal(updates.updatedBy); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml new file mode 100644 index 000000000..598780bae --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml @@ -0,0 +1,31 @@ +name: spacecat-data-access-it + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: mysticat + ports: + - "${IT_POSTGRES_PORT:-55432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 30 + + data-service: + image: ${MYSTICAT_DATA_SERVICE_IMAGE:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service:v1.7.1} + depends_on: + db: + condition: service_healthy + environment: + LOCAL_DEV: "true" + DATABASE_URL: postgres://postgrest_authenticator:postgrest@db:5432/mysticat + PGRST_DB_SCHEMAS: public + PGRST_DB_ANON_ROLE: postgrest_anon + PGRST_DB_EXTRA_SEARCH_PATH: "" + PGRST_OPENAPI_SERVER_PROXY_URI: http://localhost:${IT_POSTGREST_PORT:-3300} + ports: + - "${IT_POSTGREST_PORT:-3300}:3000" diff --git a/packages/spacecat-shared-data-access/test/it/postgrest/postgrest.test.js b/packages/spacecat-shared-data-access/test/it/postgrest/postgrest.test.js new file mode 100644 index 000000000..182e4c399 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/postgrest/postgrest.test.js @@ -0,0 +1,69 @@ +/* + * Copyright 2026 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 { expect } from 'chai'; + +import { createDataAccess } from '../../../src/service/index.js'; + +const POSTGREST_URL = process.env.POSTGREST_URL || 'http://127.0.0.1:3300'; + +const createLogger = () => ({ + info: () => {}, + debug: () => {}, + error: () => {}, + warn: () => {}, + trace: () => {}, +}); + +describe('PostgREST integration', () => { + let dataAccess; + + before(() => { + dataAccess = createDataAccess({ postgrestUrl: POSTGREST_URL }, createLogger()); + }); + + it('maps snake_case DB fields back to camelCase model fields (sites.base_url -> site.baseURL)', async () => { + const site = await dataAccess.Site.findById('0983c6da-0dee-45cc-b897-3f1fed6b460b'); + + expect(site).to.exist; + + const json = site.toJSON(); + expect(json).to.have.property('baseURL').that.is.a('string'); + expect(json).to.have.property('siteId').that.is.a('string'); + expect(json).to.not.have.property('base_url'); + expect(json).to.not.have.property('baseUrl'); + }); + + it('keeps LatestAudit as a virtual collection derived from audits', async () => { + const latest = await dataAccess.LatestAudit.all({ auditType: '404' }, { limit: 5 }); + + expect(latest).to.be.an('array').that.is.not.empty; + const first = latest[0].toJSON(); + + expect(first).to.have.property('siteId').that.is.a('string'); + expect(first).to.have.property('auditType').that.is.a('string'); + expect(first).to.have.property('auditedAt').that.is.a('string'); + expect(first).to.not.have.property('site_id'); + }); + + it('throws a v3 deprecation error for KeyEvent', async () => { + let error; + try { + await dataAccess.KeyEvent.all(); + } catch (e) { + error = e; + } + + expect(error).to.exist; + expect(error.message).to.include('KeyEvent is deprecated in data-access v3'); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/project/project.test.js b/packages/spacecat-shared-data-access/test/it/project/project.test.js deleted file mode 100644 index d4c714d13..000000000 --- a/packages/spacecat-shared-data-access/test/it/project/project.test.js +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/util/util.js'; -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -describe('Project IT', async () => { - let sampleData; - let Project; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - const dataAccess = getDataAccess(); - Project = dataAccess.Project; - }); - - it('gets all projects', async () => { - const projects = await Project.all(); - - expect(projects).to.be.an('array'); - expect(projects.length).to.equal(sampleData.projects.length); - - // Sort both arrays by project name for consistent comparison - const sortedProjects = projects.sort( - (a, b) => a.getProjectName().localeCompare(b.getProjectName()), - ); - const sortedSampleProjects = sampleData.projects.sort( - (a, b) => a.getProjectName().localeCompare(b.getProjectName()), - ); - - for (let i = 0; i < sortedProjects.length; i += 1) { - const project = sortedProjects[i]; - const sampleProject = sortedSampleProjects[i]; - - expect(project).to.be.an('object'); - expect(project.getId()).to.be.a('string'); - expect(project.getProjectName()).to.be.a('string'); - expect(project.getOrganizationId()).to.be.a('string'); - - expect( - sanitizeTimestamps(project.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleProject.toJSON()), - ); - } - }); - - it('gets a project by id', async () => { - const sampleProject = sampleData.projects[0]; - const project = await Project.findById(sampleProject.getId()); - - expect(project).to.be.an('object'); - expect( - sanitizeTimestamps(project.toJSON()), - ).to.eql( - sanitizeTimestamps(sampleProject.toJSON()), - ); - }); - - it('gets projects by organization id', async () => { - const organizationId = sampleData.organizations[0].getId(); - const projects = await Project.allByOrganizationId(organizationId); - - expect(projects).to.be.an('array'); - expect(projects.length).to.be.greaterThan(0); - - for (let i = 0; i < projects.length; i += 1) { - const project = projects[i]; - expect(project.getOrganizationId()).to.equal(organizationId); - } - }); - - it('adds a new project', async () => { - const data = { - projectName: 'New Integration Project', - organizationId: sampleData.organizations[0].getId(), - updatedBy: 'system', - }; - - const project = await Project.create(data); - - expect(project).to.be.an('object'); - expect(project.getProjectName()).to.equal(data.projectName); - expect(project.getOrganizationId()).to.equal(data.organizationId); - - expect( - sanitizeIdAndAuditFields('Project', project.toJSON()), - ).to.eql(data); - }); - - it('updates a project', async () => { - const project = await Project.findById(sampleData.projects[0].getId()); - - const data = { - projectName: 'Updated Project Name', - }; - - project.setProjectName(data.projectName); - - await project.save(); - - const updatedProject = await Project.findById(project.getId()); - - expect(updatedProject.getProjectName()).to.equal(data.projectName); - expect(updatedProject.getId()).to.equal(project.getId()); - expect(updatedProject.record.createdAt).to.equal(project.record.createdAt); - expect(updatedProject.record.updatedAt).to.not.equal(project.record.updatedAt); - }); - - it('removes a project', async () => { - const project = await Project.findById(sampleData.projects[0].getId()); - - await project.remove(); - - const notFound = await Project.findById(sampleData.projects[0].getId()); - expect(notFound).to.be.null; - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/report/report.test.js b/packages/spacecat-shared-data-access/test/it/report/report.test.js deleted file mode 100644 index c4e172438..000000000 --- a/packages/spacecat-shared-data-access/test/it/report/report.test.js +++ /dev/null @@ -1,267 +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 { isValidUUID } from '@adobe/spacecat-shared-utils'; -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import fixtures from '../../fixtures/index.fixtures.js'; -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; -import { sanitizeTimestamps } from '../../../src/util/util.js'; - -use(chaiAsPromised); - -function checkReport(report) { - expect(report).to.be.an('object'); - expect(report.getId()).to.be.a('string'); - expect(report.getReportType()).to.be.a('string'); - expect(report.getSiteId()).to.be.a('string'); - expect(report.getReportPeriod()).to.be.an('object'); - expect(report.getComparisonPeriod()).to.be.an('object'); - expect(report.getStoragePath()).to.be.a('string'); - expect(report.getStatus()).to.be.a('string'); - expect(['processing', 'success', 'failed']).to.include(report.getStatus()); - expect(report.getCreatedAt()).to.be.a('string'); - expect(report.getUpdatedAt()).to.be.a('string'); - expect(report.getUpdatedBy()).to.be.a('string'); -} - -describe('Report IT', async () => { - const { siteId } = fixtures.sites[0]; - let sampleData; - let Report; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - Report = dataAccess.Report; - }); - - it('finds one report by ID', async () => { - const sample = sampleData.reports[0]; - - const report = await Report.findById(sample.getId()); - - checkReport(report); - - expect( - sanitizeTimestamps(report.toJSON()), - ).to.eql( - sanitizeTimestamps(sample.toJSON()), - ); - }); - - it('returns null when report is not found by ID', async () => { - const report = await Report.findById('00000000-0000-0000-0000-000000000000'); - - expect(report).to.be.null; - }); - - it('finds all reports by site ID', async () => { - const sample = sampleData.reports[0]; - const testSiteId = sample.getSiteId(); - - const reports = await Report.allBySiteId(testSiteId); - - expect(reports).to.be.an('array'); - expect(reports.length).to.be.greaterThan(0); - - for (const report of reports) { - checkReport(report); - expect(report.getSiteId()).to.equal(testSiteId); - } - }); - - it('finds single report by site ID', async () => { - const sample = sampleData.reports[0]; - const testSiteId = sample.getSiteId(); - - const report = await Report.findBySiteId(testSiteId); - - expect(report).to.not.be.null; - checkReport(report); - expect(report.getSiteId()).to.equal(testSiteId); - }); - - it('finds all reports by report type', async () => { - const sample = sampleData.reports[0]; - const testReportType = sample.getReportType(); - - const reports = await Report.allByReportType(testReportType); - - expect(reports).to.be.an('array'); - expect(reports.length).to.be.greaterThan(0); - - for (const report of reports) { - checkReport(report); - expect(report.getReportType()).to.equal(testReportType); - } - }); - - it('finds single report by report type', async () => { - const sample = sampleData.reports[0]; - const testReportType = sample.getReportType(); - - const report = await Report.findByReportType(testReportType); - - expect(report).to.not.be.null; - checkReport(report); - expect(report.getReportType()).to.equal(testReportType); - }); - - it('adds a new report', async () => { - const data = { - siteId, - reportType: 'summary', - reportPeriod: { - startDate: '2025-08-01T09:00:00Z', - endDate: '2025-08-31T09:00:00Z', - }, - comparisonPeriod: { - startDate: '2025-07-01T09:00:00Z', - endDate: '2025-07-31T09:00:00Z', - }, - }; - - const report = await Report.create(data); - - checkReport(report); - - expect(isValidUUID(report.getId())).to.be.true; - expect(report.getSiteId()).to.equal(data.siteId); - expect(report.getReportType()).to.equal(data.reportType); - expect(report.getReportPeriod()).to.deep.equal(data.reportPeriod); - expect(report.getComparisonPeriod()).to.deep.equal(data.comparisonPeriod); - - // Storage path should be auto-computed with the generated reportId - const expectedStoragePath = `reports/${siteId}/summary/${report.getId()}/`; - expect(report.getStoragePath()).to.equal(expectedStoragePath); - - const record = report.toJSON(); - delete record.reportId; - delete record.createdAt; - delete record.updatedAt; - delete record.updatedBy; - // The storagePath in the record will include the auto-generated reportId - const expectedRecord = { ...data }; - expectedRecord.storagePath = `reports/${siteId}/summary/${report.getId()}/`; - expectedRecord.status = 'processing'; // Default status for new reports - expect(record).to.eql(expectedRecord); - }); - - it('adds a new report with custom storage path', async () => { - const data = { - siteId, - reportType: 'custom', - reportPeriod: { - startDate: '2025-08-01T09:00:00Z', - endDate: '2025-08-31T09:00:00Z', - }, - comparisonPeriod: { - startDate: '2025-07-01T09:00:00Z', - endDate: '2025-07-31T09:00:00Z', - }, - storagePath: '/custom/reports/path/', - }; - - const report = await Report.create(data); - - checkReport(report); - - expect(isValidUUID(report.getId())).to.be.true; - expect(report.getSiteId()).to.equal(data.siteId); - expect(report.getReportType()).to.equal(data.reportType); - expect(report.getReportPeriod()).to.deep.equal(data.reportPeriod); - expect(report.getComparisonPeriod()).to.deep.equal(data.comparisonPeriod); - expect(report.getStoragePath()).to.equal(data.storagePath); - - const record = report.toJSON(); - delete record.reportId; - delete record.createdAt; - delete record.updatedAt; - delete record.updatedBy; - - const expectedRecord = { ...data }; - expectedRecord.status = 'processing'; // Default status for new reports - expect(record).to.eql(expectedRecord); - }); - - it('adds a new report with empty storage path (auto-computed)', async () => { - const data = { - siteId, - reportType: 'auto-computed', - reportPeriod: { - startDate: '2025-08-01T09:00:00Z', - endDate: '2025-08-31T09:00:00Z', - }, - comparisonPeriod: { - startDate: '2025-07-01T09:00:00Z', - endDate: '2025-07-31T09:00:00Z', - }, - storagePath: '', // Explicitly set to empty string - }; - - const report = await Report.create(data); - - checkReport(report); - - expect(isValidUUID(report.getId())).to.be.true; - expect(report.getSiteId()).to.equal(data.siteId); - expect(report.getReportType()).to.equal(data.reportType); - expect(report.getReportPeriod()).to.deep.equal(data.reportPeriod); - expect(report.getComparisonPeriod()).to.deep.equal(data.comparisonPeriod); - - // Storage path should be auto-computed since it was empty - const expectedStoragePath = `reports/${siteId}/auto-computed/${report.getId()}/`; - expect(report.getStoragePath()).to.equal(expectedStoragePath); - }); - - it('updates a report', async () => { - const sample = sampleData.reports[0]; - const updates = { - reportType: 'updated-type', - reportPeriod: { - startDate: '2025-09-01T09:00:00Z', - endDate: '2025-09-30T09:00:00Z', - }, - comparisonPeriod: { - startDate: '2025-08-01T09:00:00Z', - endDate: '2025-08-31T09:00:00Z', - }, - storagePath: 'reports/updated/path/', - updatedBy: 'test-user', - }; - - const report = await Report.findById(sample.getId()); - - report.setReportType(updates.reportType); - report.setReportPeriod(updates.reportPeriod); - report.setComparisonPeriod(updates.comparisonPeriod); - report.setStoragePath(updates.storagePath); - report.setUpdatedBy(updates.updatedBy); - - await report.save(); - - checkReport(report); - - expect(report.getReportType()).to.equal(updates.reportType); - expect(report.getReportPeriod()).to.deep.equal(updates.reportPeriod); - expect(report.getComparisonPeriod()).to.deep.equal(updates.comparisonPeriod); - expect(report.getStoragePath()).to.equal(updates.storagePath); - expect(report.getUpdatedBy()).to.equal(updates.updatedBy); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/scrape-job/scrape-job.test.js b/packages/spacecat-shared-data-access/test/it/scrape-job/scrape-job.test.js deleted file mode 100644 index 92547645d..000000000 --- a/packages/spacecat-shared-data-access/test/it/scrape-job/scrape-job.test.js +++ /dev/null @@ -1,238 +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 } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import ScrapeJobModel from '../../../src/models/scrape-job/scrape-job.model.js'; -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; - -use(chaiAsPromised); - -function checkScrapeJob(scrapeJob) { - expect(scrapeJob).to.be.an('object'); - expect(scrapeJob.getBaseURL()).to.be.a('string'); - expect(scrapeJob.getDuration()).to.be.a('number'); - expect(scrapeJob.getFailedCount()).to.be.a('number'); - if (scrapeJob.getCustomHeaders()) { - expect(scrapeJob.getCustomHeaders()).to.be.a('object'); - } - expect(scrapeJob.getScrapeQueueId()).to.be.a('string'); - expect(scrapeJob.getRedirectCount()).to.be.an('number'); - expect(scrapeJob.getStartedAt()).to.be.a('string'); - expect(scrapeJob.getStatus()).to.be.a('string'); - expect(scrapeJob.getSuccessCount()).to.be.an('number'); - expect(scrapeJob.getUrlCount()).to.be.an('number'); - expect(scrapeJob.getProcessingType()).to.be.a('string'); - expect(scrapeJob.getOptions()).to.be.an('object'); -} - -describe('ScrapeJob IT', async () => { - let sampleData; - let ScrapeJob; - let newJobData; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - ScrapeJob = dataAccess.ScrapeJob; - - newJobData = { - scrapeQueueId: 'some-queue-id', - baseURL: 'https://example-some.com/cars', - startedAt: '2023-12-15T01:22:05.000Z', - status: ScrapeJobModel.ScrapeJobStatus.RUNNING, - processingType: ScrapeJobModel.ScrapeProcessingType.DEFAULT, - customHeader: {}, - options: { - enableJavascript: true, - pageLoadTimeout: 10000, - hideConsentBanners: false, - waitForSelector: 'body', - screenshotTypes: [ - ScrapeJobModel.ScrapeScreenshotType.FULL_PAGE, - ScrapeJobModel.ScrapeScreenshotType.THUMBNAIL, - ], - }, - }; - }); - - it('adds a new scrape job', async () => { - const scrapeJob = await ScrapeJob.create(newJobData); - - checkScrapeJob(scrapeJob); - - expect(scrapeJob.getScrapeQueueId()).to.equal(newJobData.scrapeQueueId); - expect(scrapeJob.getBaseURL()).to.equal(newJobData.baseURL); - expect(scrapeJob.getStartedAt()).to.equal(newJobData.startedAt); - expect(scrapeJob.getStatus()).to.equal(newJobData.status); - expect(scrapeJob.getCustomHeaders()).to.equal(newJobData.customHeaders); - expect(scrapeJob.getProcessingType()).to.equal(newJobData.processingType); - expect(scrapeJob.getOptions()).to.eql(newJobData.options); - }); - - it('adds a new scrape job with valid options', async () => { - const options = { - enableJavascript: true, - pageLoadTimeout: 5000, - hideConsentBanners: true, - waitForSelector: 'body', - screenshotTypes: [ - ScrapeJobModel.ScrapeScreenshotType.SCROLL, - ScrapeJobModel.ScrapeScreenshotType.BLOCK, - ], - }; - - let validJobData = { ...newJobData, options }; - let scrapeJob = await ScrapeJob.create(validJobData); - - checkScrapeJob(scrapeJob); - expect(scrapeJob.getOptions()).to.eql(validJobData.options); - - validJobData = { - ...newJobData, - processingType: ScrapeJobModel.ScrapeProcessingType.ACCESSIBILITY, - }; - scrapeJob = await ScrapeJob.create(validJobData); - - checkScrapeJob(scrapeJob); - expect(scrapeJob.getProcessingType()).to.eql(ScrapeJobModel.ScrapeProcessingType.ACCESSIBILITY); - }); - - it('updates an existing scrape job', async () => { - const sampleScrapeJob = sampleData.scrapeJobs[0]; - const scrapeJob = await ScrapeJob.findById(sampleScrapeJob.getId()); - - const updates = { - status: 'COMPLETE', - endedAt: '2023-11-15T03:49:13.000Z', - successCount: 86, - failedCount: 4, - redirectCount: 10, - urlCount: 100, - duration: 188000, - }; - - await scrapeJob - .setStatus(updates.status) - .setEndedAt(updates.endedAt) - .setSuccessCount(updates.successCount) - .setFailedCount(updates.failedCount) - .setRedirectCount(updates.redirectCount) - .setUrlCount(updates.urlCount) - .setDuration(updates.duration) - .save(); - - const updatedScrapeJob = await ScrapeJob.findById(scrapeJob.getId()); - - checkScrapeJob(updatedScrapeJob); - - expect(updatedScrapeJob.getStatus()).to.equal(updates.status); - expect(updatedScrapeJob.getEndedAt()).to.equal(updates.endedAt); - expect(updatedScrapeJob.getSuccessCount()).to.equal(updates.successCount); - expect(updatedScrapeJob.getFailedCount()).to.equal(updates.failedCount); - expect(updatedScrapeJob.getRedirectCount()).to.equal(updates.redirectCount); - expect(updatedScrapeJob.getUrlCount()).to.equal(updates.urlCount); - expect(updatedScrapeJob.getDuration()).to.equal(updates.duration); - }); - - it('finds a scrape job by its id', async () => { - const sampleScrapeJob = sampleData.scrapeJobs[0]; - const scrapeJob = await ScrapeJob.findById(sampleScrapeJob.getId()); - - checkScrapeJob(scrapeJob); - expect(scrapeJob.getId()).to.equal(sampleScrapeJob.getId()); - }); - - it('gets all scrape jobs by status', async () => { - const scrapeJobs = await ScrapeJob.allByStatus(ScrapeJobModel.ScrapeJobStatus.COMPLETE); - - expect(scrapeJobs).to.be.an('array'); - expect(scrapeJobs.length).to.equal(1); - expect(scrapeJobs[0].getId()).to.equal(sampleData.scrapeJobs[0].getId()); - scrapeJobs.forEach((scrapeJob) => { - checkScrapeJob(scrapeJob); - expect(scrapeJob.getStatus()).to.equal(ScrapeJobModel.ScrapeJobStatus.COMPLETE); - }); - }); - - it('gets all scrape jobs by date range', async () => { - const scrapeJobs = await ScrapeJob.allByDateRange( - '2023-11-14T00:00:00.000Z', - '2023-11-16T00:00:00.000Z', - ); - - expect(scrapeJobs).to.be.an('array'); - expect(scrapeJobs.length).to.equal(3); - - scrapeJobs.forEach((scrapeJob) => { - checkScrapeJob(scrapeJob); - }); - }); - - it('gets all scrape jobs by baseURL', async () => { - const scrapeJobs = await ScrapeJob.allByBaseURL('https://example-2.com/cars'); - - expect(scrapeJobs).to.be.an('array'); - expect(scrapeJobs.length).to.equal(2); - expect(scrapeJobs[0].getId()).to.equal(sampleData.scrapeJobs[1].getId()); - expect(scrapeJobs[1].getId()).to.equal(sampleData.scrapeJobs[0].getId()); - }); - - it('gets all scrape jobs by baseURL and processing type', async () => { - const scrapeJobs = await ScrapeJob.allByBaseURLAndProcessingType('https://example-2.com/cars', ScrapeJobModel.ScrapeProcessingType.DEFAULT); - - expect(scrapeJobs).to.be.an('array'); - expect(scrapeJobs.length).to.equal(1); - expect(scrapeJobs[0].getId()).to.equal(sampleData.scrapeJobs[0].getId()); - }); - - it('removes a scrape job', async () => { - const sampleScrapeJob = sampleData.scrapeJobs[0]; - const scrapeJob = await ScrapeJob.findById(sampleScrapeJob.getId()); - - const scrapeUrls = await scrapeJob.getScrapeUrls(); - - expect(scrapeUrls).to.be.an('array'); - expect(scrapeUrls.length).to.equal(5); - - await scrapeJob.remove(); - - const removedScrapeJob = await ScrapeJob.findById(sampleScrapeJob.getId()); - expect(removedScrapeJob).to.be.null; - }); - - it('stores and retrieves abortInfo', async () => { - const abortInfo = { - reason: 'bot-protection', - details: { - blockedUrlsCount: 5, - totalUrlsCount: 10, - blockedUrls: [{ url: 'https://example.com/page1', blockerType: 'cloudflare', httpStatus: 403 }], - byBlockerType: { cloudflare: 5 }, - byHttpStatus: { 403: 5 }, - }, - }; - - const scrapeJob = await ScrapeJob.create({ ...newJobData }); - scrapeJob.setAbortInfo(abortInfo); - await scrapeJob.save(); - - const retrieved = await ScrapeJob.findById(scrapeJob.getId()); - expect(retrieved.getAbortInfo()).to.deep.equal(abortInfo); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/scrape-url/scrape-url.test.js b/packages/spacecat-shared-data-access/test/it/scrape-url/scrape-url.test.js deleted file mode 100644 index 297fc9a30..000000000 --- a/packages/spacecat-shared-data-access/test/it/scrape-url/scrape-url.test.js +++ /dev/null @@ -1,140 +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 } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -import { getDataAccess } from '../util/db.js'; -import { seedDatabase } from '../util/seed.js'; -import { ScrapeJob } from '../../../src/index.js'; - -use(chaiAsPromised); - -function checkScrapeUrl(scrapeUrl) { - expect(scrapeUrl).to.be.an('object'); - expect(scrapeUrl.getRecordExpiresAt()).to.be.a('number'); - expect(scrapeUrl.getScrapeJobId()).to.be.a('string'); - expect(scrapeUrl.getStatus()).to.be.a('string'); - expect(scrapeUrl.getUrl()).to.be.a('string'); -} - -describe('ScrapeUrl IT', async () => { - let sampleData; - let ScrapeUrl; - - before(async function () { - this.timeout(10000); - sampleData = await seedDatabase(); - - const dataAccess = getDataAccess(); - ScrapeUrl = dataAccess.ScrapeUrl; - }); - - it('adds a new scrape url', async () => { - const sampleScrapeJob = sampleData.scrapeJobs[0]; - const data = { - scrapeJobId: sampleScrapeJob.getId(), - url: 'https://example-some.com/cars', - status: 'RUNNING', - processingType: ScrapeJob.ScrapeProcessingType.DEFAULT, - - }; - - const scrapeUrl = await ScrapeUrl.create(data); - - checkScrapeUrl(scrapeUrl); - }); - - it('updates an scrape url', async () => { - const data = { - url: 'https://example-some.com/cars', - status: 'RUNNING', - file: 'some-file', - reason: 'some-reason', - }; - - const scrapeUrl = await ScrapeUrl.findById(sampleData.scrapeUrls[0].getId()); - await scrapeUrl - .setUrl(data.url) - .setStatus(data.status) - .setFile(data.file) - .setReason(data.reason) - .save(); - - const updatedScrapeUrl = await ScrapeUrl.findById(sampleData.scrapeUrls[0].getId()); - - checkScrapeUrl(updatedScrapeUrl); - - expect(updatedScrapeUrl.getStatus()).to.equal(data.status); - expect(updatedScrapeUrl.getUrl()).to.equal(data.url); - expect(updatedScrapeUrl.getFile()).to.equal(data.file); - expect(updatedScrapeUrl.getReason()).to.equal(data.reason); - }); - - it('it gets all scrape urls by scrape job id', async () => { - const scrapeJob = sampleData.scrapeJobs[0]; - const scrapeUrls = await ScrapeUrl.allByScrapeJobId(scrapeJob.getId()); - - expect(scrapeUrls).to.be.an('array'); - expect(scrapeUrls.length).to.equal(6); - - scrapeUrls.forEach((scrapeUrl) => { - expect(scrapeUrl.getScrapeJobId()).to.equal(scrapeJob.getId()); - checkScrapeUrl(scrapeUrl); - }); - }); - - it('it gets all scrape urls by job id and status', async () => { - const scrapeJob = sampleData.scrapeJobs[0]; - const scrapeUrls = await ScrapeUrl.allByScrapeJobIdAndStatus(scrapeJob.getId(), 'RUNNING'); - - expect(scrapeUrls).to.be.an('array'); - expect(scrapeUrls.length).to.equal(2); - - scrapeUrls.forEach((scrapeUrl) => { - expect(scrapeUrl.getScrapeJobId()).to.equal(scrapeJob.getId()); - expect(scrapeUrl.getStatus()).to.equal('RUNNING'); - checkScrapeUrl(scrapeUrl); - }); - }); - - it('it gets all scrape urls for a given job by the URL status', async () => { - const scrapeJob = sampleData.scrapeJobs[0]; - const scrapeUrls = await scrapeJob.getScrapeUrlsByStatus('FAILED'); - - expect(scrapeUrls).to.be.an('array'); - expect(scrapeUrls.length).to.equal(1); - expect(scrapeUrls[0].getReason()).to.equal('Failed to scrape the URL: Something went wrong. Oops!'); - expect(scrapeUrls[0].getScrapeJobId()).to.equal(scrapeJob.getId()); - expect(scrapeUrls[0].getStatus()).to.equal('FAILED'); - }); - - it('finds an scrape url by its id', async () => { - const sampleScrapeUrl = sampleData.scrapeUrls[0]; - const scrapeUrl = await ScrapeUrl.findById(sampleScrapeUrl.getId()); - - checkScrapeUrl(scrapeUrl); - expect(scrapeUrl.getId()).to.equal(sampleScrapeUrl.getId()); - }); - - it('removes an scrape url', async () => { - const sampleScrapeUrl = sampleData.scrapeUrls[0]; - const scrapeUrl = await ScrapeUrl.findById(sampleScrapeUrl.getId()); - - await scrapeUrl.remove(); - - const removedScrapeUrl = await ScrapeUrl.findById(sampleScrapeUrl.getId()); - expect(removedScrapeUrl).to.be.null; - }); -}); diff --git a/packages/spacecat-shared-data-access/test/it/seed/tenants/01_tenant_alpha.sql b/packages/spacecat-shared-data-access/test/it/seed/tenants/01_tenant_alpha.sql new file mode 100644 index 000000000..ea0f0c351 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/seed/tenants/01_tenant_alpha.sql @@ -0,0 +1,3159 @@ +-- Seed data for Tenant Alpha +-- Organization ID: 04f63783-3f76-4076-bbda-71a11145303c +-- Generated: 2026-02-02T18:10:09.735272 + +-- Organization +INSERT INTO organizations (id, name, ims_org_id, config, fulfillable_items, created_at, updated_at, updated_by) VALUES ( + '04f63783-3f76-4076-bbda-71a11145303c', + 'Tenant Alpha Experience Cloud', + 'F489CFB4556ECF927F000101@AdobeOrg', + '{"handlers": {}, "slack": {"channel": "C04D51RSGLT", "workspace": "WORKSPACE_EXTERNAL"}}'::jsonb, + '{"aem_sites_optimizer": {"items": ["dx_aem_perf_content_requests", "dx_aem_perf_auto_fix", "dx_aem_perf_support", "dx_aem_perf_auto_suggest", "wf_instance", "dma_acp_cs", "esm_user_storage", "esm_shared_storage", "asset_sharing_policy_config", "user_group_assignment", "core_services_cc", "domain_claiming", "user_sync", "overdelegation_allowed", "support_case_creation_allowed"]}}'::jsonb, + '2024-12-20T07:31:25.919Z'::timestamptz, + '2025-07-09T22:23:52.300Z'::timestamptz, + 'system' +); + +-- Entitlements +INSERT INTO entitlements (id, organization_id, product_code, tier, quotas, created_at, updated_at, updated_by) VALUES ( + '0914fe7c-6e23-427b-9db2-106c234d5dfd', + '04f63783-3f76-4076-bbda-71a11145303c', + 'ASO'::entitlement_product_code, + 'PAID'::entitlement_tier, + '{"llmo_trial_prompts": 200, "llmo_trial_prompts_consumed": 0}'::jsonb, + '2025-10-15T14:41:52.193Z'::timestamptz, + '2025-10-15T14:41:52.193Z'::timestamptz, + 'system' +); +INSERT INTO entitlements (id, organization_id, product_code, tier, quotas, created_at, updated_at, updated_by) VALUES ( + '9b8cf1c5-1f1b-422e-a159-9847fd1fe808', + '04f63783-3f76-4076-bbda-71a11145303c', + 'LLMO'::entitlement_product_code, + 'FREE_TRIAL'::entitlement_tier, + '{"llmo_trial_prompts": 200, "llmo_trial_prompts_consumed": 0}'::jsonb, + '2025-10-08T12:44:45.391Z'::timestamptz, + '2025-10-08T12:44:45.392Z'::timestamptz, + 'system' +); + +-- Projects +INSERT INTO projects (id, organization_id, project_name, created_at, updated_at, updated_by) VALUES ( + '6fc3365d-f400-4a17-91e9-7141e5b82350', + '04f63783-3f76-4076-bbda-71a11145303c', + 'tenant-alphaland.com', + '2025-11-24T16:36:21.139Z'::timestamptz, + '2025-11-24T16:36:21.139Z'::timestamptz, + 'system' +); +INSERT INTO projects (id, organization_id, project_name, created_at, updated_at, updated_by) VALUES ( + '6de5d214-15c5-4969-b074-f95e8bc237a1', + '04f63783-3f76-4076-bbda-71a11145303c', + 'tenant-alpha-secondary.com', + '2025-11-24T17:07:12.928Z'::timestamptz, + '2025-11-24T17:07:12.928Z'::timestamptz, + 'system' +); + +-- Sites +INSERT INTO sites (id, organization_id, base_url, name, is_primary_locale, language, region, config, code, delivery_type, authoring_type, github_url, delivery_config, hlx_config, is_sandbox, is_live, is_live_toggled_at, external_owner_id, external_site_id, page_types, project_id, created_at, updated_at, updated_by) VALUES ( + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '04f63783-3f76-4076-bbda-71a11145303c', + 'https://tenant-alphaland.com', + 'tenant-alphaland.com', + NULL, + 'en', + 'US', + '{"handlers": {"cwv": {"groupedURLs": [{"name": "Products", "pattern": "/product"}]}}, "imports": [{"sources": ["ahrefs"], "type": "organic-traffic", "enabled": true, "destinations": ["default"]}, {"sources": ["ahrefs"], "type": "organic-keywords", "enabled": true, "destinations": ["default"]}, {"type": "cwv-daily", "sources": ["rum"], "enabled": true, "destinations": ["default"]}, {"type": "traffic-analysis", "sources": ["rum"], "enabled": true, "destinations": ["default"]}, {"sources": ["rum"], "type": "all-traffic", "enabled": true, "destinations": ["default"]}, {"type": "user-engagement", "sources": ["rum"], "enabled": false, "destinations": ["default"]}, {"type": "cwv-weekly", "sources": ["rum"], "enabled": false, "destinations": ["default"]}, {"geo": "global", "type": "top-pages", "sources": ["ahrefs"], "enabled": true, "destinations": ["default"]}]}'::jsonb, + '{}'::jsonb, + 'aem_cs'::delivery_type, + 'cs'::authoring_type, + NULL, + '{"__reindex": "now", "siteId": "51ea4b7c-1af2-4b6d-8c61-b3adfd065f8d", "environmentId": "440257", "programId": "50513", "authorURL": "https://author-p50513-e440257.adobeaemcloud.com"}'::jsonb, + '{}'::jsonb, + false, + true, + NULL::timestamptz, + 'p50513', + 'e440257', + NULL::jsonb, + '6fc3365d-f400-4a17-91e9-7141e5b82350', + '2024-12-17T09:54:09.625Z'::timestamptz, + '2026-01-28T05:12:19.151Z'::timestamptz, + 'system' +); +INSERT INTO sites (id, organization_id, base_url, name, is_primary_locale, language, region, config, code, delivery_type, authoring_type, github_url, delivery_config, hlx_config, is_sandbox, is_live, is_live_toggled_at, external_owner_id, external_site_id, page_types, project_id, created_at, updated_at, updated_by) VALUES ( + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '04f63783-3f76-4076-bbda-71a11145303c', + 'https://tenant-alpha-secondary.com', + NULL, + NULL, + 'en', + 'US', + '{"imports": [{"sources": ["ahrefs"], "type": "organic-traffic", "enabled": true, "destinations": ["default"]}, {"sources": ["ahrefs"], "type": "organic-keywords", "enabled": true, "destinations": ["default"]}, {"geo": "global", "type": "top-pages", "sources": ["ahrefs"], "enabled": true, "destinations": ["default"]}, {"sources": ["rum"], "type": "all-traffic", "enabled": true, "destinations": ["default"]}, {"type": "traffic-analysis", "sources": ["rum"], "enabled": true, "destinations": ["default"]}]}'::jsonb, + '{}'::jsonb, + 'aem_cs'::delivery_type, + 'cs'::authoring_type, + NULL, + '{"siteId": "c3e977e3-d30a-47e4-a78f-2f92449a03d1", "environmentId": "440257", "programId": "50513", "authorURL": "https://author-p50513-e440257.adobeaemcloud.com"}'::jsonb, + '{}'::jsonb, + false, + true, + '2024-12-03T15:58:06.464Z'::timestamptz, + 'p50513', + 'e440257', + NULL::jsonb, + '6de5d214-15c5-4969-b074-f95e8bc237a1', + '2024-11-27T15:46:29.760Z'::timestamptz, + '2026-02-02T06:59:59.452Z'::timestamptz, + 'system' +); + +-- Site Enrollments +INSERT INTO site_enrollments (id, site_id, entitlement_id, created_at, updated_at, updated_by) VALUES ( + 'a820d82a-6406-4803-b446-ebf03ed5293b', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '0914fe7c-6e23-427b-9db2-106c234d5dfd', + '2025-10-15T14:41:54.326Z'::timestamptz, + '2025-10-15T14:41:54.326Z'::timestamptz, + 'system' +); +INSERT INTO site_enrollments (id, site_id, entitlement_id, created_at, updated_at, updated_by) VALUES ( + 'c85c9552-38ad-4df4-b814-a5a644cdb57b', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '0914fe7c-6e23-427b-9db2-106c234d5dfd', + '2025-10-15T14:41:52.488Z'::timestamptz, + '2025-10-15T14:41:52.488Z'::timestamptz, + 'system' +); + +-- Audits (limited sample) +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '74a041ed-a15c-4bdc-a38c-5203b22d2ab3', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2024-12-29&enddate=2025-01-05', + true, + false, + NULL, + '2025-01-05T05:05:54.775Z'::timestamptz, + '2025-01-05T05:05:54.775Z'::timestamptz, + '2025-01-05T05:05:54.775Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '66912d53-7ac7-4a27-8ba3-906c0b810d2d', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-01-05&enddate=2025-01-12', + true, + false, + NULL, + '2025-01-12T05:11:21.970Z'::timestamptz, + '2025-01-12T05:11:21.970Z'::timestamptz, + '2025-01-12T05:11:21.970Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'fd44482c-c361-47b3-ad27-727af2372862', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-01-12&enddate=2025-01-19', + true, + false, + NULL, + '2025-01-19T05:06:45.012Z'::timestamptz, + '2025-01-19T05:06:45.012Z'::timestamptz, + '2025-01-19T05:06:45.012Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '5d892e04-ea6a-4e46-9fb9-68dc19d34bcb', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-01-19&enddate=2025-01-26', + true, + false, + NULL, + '2025-01-26T05:06:19.947Z'::timestamptz, + '2025-01-26T05:06:19.947Z'::timestamptz, + '2025-01-26T05:06:19.947Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'f24bc09f-b134-4a11-884d-29e65da71ce5', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-01-26&enddate=2025-02-02', + true, + false, + NULL, + '2025-02-02T05:07:49.497Z'::timestamptz, + '2025-02-02T05:07:49.498Z'::timestamptz, + '2025-02-02T05:07:49.498Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '12c1c248-2a66-48e7-af98-47ac904bb8e0', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-02-02&enddate=2025-02-09', + true, + false, + NULL, + '2025-02-09T05:06:16.537Z'::timestamptz, + '2025-02-09T05:06:16.537Z'::timestamptz, + '2025-02-09T05:06:16.538Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '1eb51889-e7a4-44e9-9cd8-19907fe489f1', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-02-09&enddate=2025-02-16', + true, + false, + NULL, + '2025-02-16T05:05:16.228Z'::timestamptz, + '2025-02-16T05:05:16.228Z'::timestamptz, + '2025-02-16T05:05:16.228Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '2da858d8-1a22-4284-b1b8-fda95eb6ae1c', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-02-16&enddate=2025-02-23', + true, + false, + NULL, + '2025-02-23T05:03:56.384Z'::timestamptz, + '2025-02-23T05:03:56.384Z'::timestamptz, + '2025-02-23T05:03:56.384Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '7c1529e3-3dde-469f-bb94-aec953c6535d', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-02-23&enddate=2025-03-02', + true, + false, + NULL, + '2025-03-02T05:04:47.913Z'::timestamptz, + '2025-03-02T05:04:47.913Z'::timestamptz, + '2025-03-02T05:04:47.913Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'c0e93f11-4b2c-40da-882d-6cafe461e35e', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-04-30&enddate=2025-05-07', + true, + false, + NULL, + '2025-05-07T15:21:25.703Z'::timestamptz, + '2025-05-07T15:21:25.704Z'::timestamptz, + '2025-05-07T15:21:25.705Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'ce16afb0-4dfc-4fc2-add2-646053b91646', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-05-14&enddate=2025-05-21', + true, + false, + NULL, + '2025-05-21T12:50:11.186Z'::timestamptz, + '2025-05-21T12:50:11.187Z'::timestamptz, + '2025-05-21T12:50:11.189Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '9fce3b9d-9d4c-4b87-a68d-125474a0d733', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-05-28&enddate=2025-06-04', + true, + false, + NULL, + '2025-06-04T13:43:51.851Z'::timestamptz, + '2025-06-04T13:43:51.852Z'::timestamptz, + '2025-06-04T13:43:51.852Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '1cb69592-3f4b-46a2-9e58-76be071ae60c', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-05-29&enddate=2025-06-05', + true, + false, + NULL, + '2025-06-05T09:07:29.421Z'::timestamptz, + '2025-06-05T09:07:29.421Z'::timestamptz, + '2025-06-05T09:07:29.422Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '0432823c-49f7-41e5-b227-1b13d627e45a', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + '404', + '[{"sources": [], "url": "https://www.tenant-alphaland.com/Pokemon.", "pageviews": "200"}]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alphaland.com&startdate=2025-06-09&enddate=2025-06-16', + true, + false, + NULL, + '2025-06-16T10:24:25.803Z'::timestamptz, + '2025-06-16T10:24:25.804Z'::timestamptz, + '2025-06-16T10:24:25.805Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '52e102c4-d811-4ed4-b75d-730962615ac0', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + 'accessibility', + '{"finalUrl": "www.tenant-alphaland.com", "status": "preparing"}'::jsonb, + 'scrapes/0983c6da-0dee-45cc-b897-3f1fed6b460b/', + true, + false, + NULL, + '2025-08-06T09:07:25.383Z'::timestamptz, + '2025-08-06T09:07:25.383Z'::timestamptz, + '2025-08-06T09:07:25.383Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'd1cb0e9f-ea8b-48ab-a2d4-299b1905f482', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + 'accessibility', + '{"finalUrl": "www.tenant-alphaland.com", "status": "preparing"}'::jsonb, + 'scrapes/0983c6da-0dee-45cc-b897-3f1fed6b460b/', + true, + false, + NULL, + '2025-08-13T07:17:34.873Z'::timestamptz, + '2025-08-13T07:17:34.873Z'::timestamptz, + '2025-08-13T07:17:34.874Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '69c3d0c3-e345-4cab-bafd-77cb93c7cef2', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + 'accessibility', + '{"finalUrl": "www.tenant-alphaland.com", "status": "preparing"}'::jsonb, + 'scrapes/0983c6da-0dee-45cc-b897-3f1fed6b460b/', + true, + false, + NULL, + '2025-08-18T06:00:13.372Z'::timestamptz, + '2025-08-18T06:00:13.372Z'::timestamptz, + '2025-08-18T06:00:13.373Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '96bde89e-9f0b-4218-a2bb-e82d50f57900', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + 'accessibility', + '{"finalUrl": "www.tenant-alphaland.com", "status": "preparing"}'::jsonb, + 'scrapes/0983c6da-0dee-45cc-b897-3f1fed6b460b/', + true, + false, + NULL, + '2025-08-25T06:00:20.199Z'::timestamptz, + '2025-08-25T06:00:20.200Z'::timestamptz, + '2025-08-25T06:00:20.200Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '0dca7bb4-6af2-4bf1-978b-a46c7268ae4d', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + 'accessibility', + '{"finalUrl": "www.tenant-alphaland.com", "status": "preparing"}'::jsonb, + 'scrapes/0983c6da-0dee-45cc-b897-3f1fed6b460b/', + true, + false, + NULL, + '2025-09-01T06:00:25.800Z'::timestamptz, + '2025-09-01T06:00:25.801Z'::timestamptz, + '2025-09-01T06:00:25.803Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '7756bbeb-9127-43e1-b7a9-3b0eeda414df', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + 'accessibility', + '{"finalUrl": "www.tenant-alphaland.com", "status": "preparing"}'::jsonb, + 'scrapes/0983c6da-0dee-45cc-b897-3f1fed6b460b/', + true, + false, + NULL, + '2025-09-08T06:00:12.116Z'::timestamptz, + '2025-09-08T06:00:12.116Z'::timestamptz, + '2025-09-08T06:00:12.117Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '5d1eeb9e-60be-41e3-8ddf-3e5d9afd2bbf', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2024-12-29&enddate=2025-01-05', + true, + false, + NULL, + '2025-01-05T05:07:08.029Z'::timestamptz, + '2025-01-05T05:07:08.030Z'::timestamptz, + '2025-01-05T05:07:08.030Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '6a7850b9-bf40-423c-b5c6-68c772c33c71', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-01-05&enddate=2025-01-12', + true, + false, + NULL, + '2025-01-12T05:14:10.521Z'::timestamptz, + '2025-01-12T05:14:10.521Z'::timestamptz, + '2025-01-12T05:14:10.521Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'b971187f-e93d-44c0-a879-bc363b613a8e', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-01-12&enddate=2025-01-19', + true, + false, + NULL, + '2025-01-19T05:07:39.648Z'::timestamptz, + '2025-01-19T05:07:39.648Z'::timestamptz, + '2025-01-19T05:07:39.648Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'c3109ff2-2068-4989-984f-1ebdae569c3a', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-01-19&enddate=2025-01-26', + true, + false, + NULL, + '2025-01-26T05:06:46.736Z'::timestamptz, + '2025-01-26T05:06:46.736Z'::timestamptz, + '2025-01-26T05:06:46.736Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '28ecb62c-a03b-40ae-b458-d21419dd5536', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-01-26&enddate=2025-02-02', + true, + false, + NULL, + '2025-02-02T05:07:59.066Z'::timestamptz, + '2025-02-02T05:07:59.066Z'::timestamptz, + '2025-02-02T05:07:59.066Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'ec8f07da-c38d-42c3-85f3-f5c473cc0b47', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-02-02&enddate=2025-02-09', + true, + false, + NULL, + '2025-02-09T05:06:15.938Z'::timestamptz, + '2025-02-09T05:06:15.938Z'::timestamptz, + '2025-02-09T05:06:15.939Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '41f027a9-5556-448e-8700-baeeccca71f5', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-02-09&enddate=2025-02-16', + true, + false, + NULL, + '2025-02-16T05:06:02.538Z'::timestamptz, + '2025-02-16T05:06:02.538Z'::timestamptz, + '2025-02-16T05:06:02.538Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'be51fdcf-ba28-4485-9d60-185452cd951b', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-02-16&enddate=2025-02-23', + true, + false, + NULL, + '2025-02-23T05:04:44.621Z'::timestamptz, + '2025-02-23T05:04:44.621Z'::timestamptz, + '2025-02-23T05:04:44.621Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'f449d95c-b59e-4b21-b6d7-d26d3a34cdaf', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-02-23&enddate=2025-03-02', + true, + false, + NULL, + '2025-03-02T05:02:02.453Z'::timestamptz, + '2025-03-02T05:02:02.453Z'::timestamptz, + '2025-03-02T05:02:02.453Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '6c9aa772-c7ce-455e-9a50-892543d99ccc', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-04-30&enddate=2025-05-07', + true, + false, + NULL, + '2025-05-07T15:21:29.207Z'::timestamptz, + '2025-05-07T15:21:29.207Z'::timestamptz, + '2025-05-07T15:21:29.207Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'b20d5a57-4936-4666-988b-6f4b652f710c', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '404', + '[]'::jsonb, + 'https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-404?interval=-1&offset=0&limit=101&url=www.tenant-alpha-secondary.com&startdate=2025-05-28&enddate=2025-06-04', + true, + false, + NULL, + '2025-06-04T13:44:09.371Z'::timestamptz, + '2025-06-04T13:44:09.371Z'::timestamptz, + '2025-06-04T13:44:09.371Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'bed1f8a8-7ca5-4438-8b6a-5bf410628018', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + 'accessibility', + '{"finalUrl": "www.tenant-alpha-secondary.com", "status": "preparing"}'::jsonb, + 'scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/', + true, + false, + NULL, + '2025-08-07T12:49:45.547Z'::timestamptz, + '2025-08-07T12:49:45.548Z'::timestamptz, + '2025-08-07T12:49:45.550Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '97778cfc-c014-4393-a5a4-b8cdceb4bb80', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + 'accessibility', + '{"finalUrl": "www.tenant-alpha-secondary.com", "status": "preparing"}'::jsonb, + 'scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/', + true, + false, + NULL, + '2025-09-29T07:14:29.354Z'::timestamptz, + '2025-09-29T07:14:29.354Z'::timestamptz, + '2025-09-29T07:14:29.354Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '1758c317-94a0-45d5-87f8-28baeba4973e', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + 'accessibility', + '{"finalUrl": "www.tenant-alpha-secondary.com", "status": "preparing"}'::jsonb, + 'scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/', + true, + false, + NULL, + '2025-10-06T07:16:30.821Z'::timestamptz, + '2025-10-06T07:16:30.821Z'::timestamptz, + '2025-10-06T07:16:30.821Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'a5590c4f-d897-42e5-ab54-fc7604ab8afb', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + 'accessibility', + '{"finalUrl": "www.tenant-alpha-secondary.com", "status": "preparing"}'::jsonb, + 'scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/', + true, + false, + NULL, + '2025-10-13T07:11:49.673Z'::timestamptz, + '2025-10-13T07:11:49.673Z'::timestamptz, + '2025-10-13T07:11:49.673Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'ad7a6aa0-09e0-458b-ab7e-b55f4d4b15a5', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + 'alt-text', + '{"sourceS3Folder": "spacecat-scraper/scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "fullAuditRef": "scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "detectedTags": {"imagesWithoutAltText": [{"src": "/content/dam/tenant-alpha-chocolate-world/images/blog/our-sweet-history/storytelling-video-historicaltour.jpg", "pageUrl": "/blog/history.html"}, {"src": "/plan-your-visit/amenities-and-accessibility/_jcr_content/root/container/section_1887685911/col1/section/col2/image.coreimg.png/1645138368408/amenities-badge-cac.png", "pageUrl": "/plan-your-visit/amenities-and-accessibility.html"}, {"src": "/plan-your-visit/groups-and-parties/_jcr_content/root/container/section_410668149_co_292676950/col2/call_out_tile/image.img.jpg/1694802555244.jpg?im=Resize=(193)", "pageUrl": "/plan-your-visit/groups-and-parties.html"}, {"src": "/things-to-do/reeses-stuff-your-cup-ingredients/_jcr_content/root/container/section_article_over/col1/section_copy/col2/image.coreimg.png/1677778544157/reeses-syc-logo.png", "pageUrl": "/things-to-do/reeses-stuff-your-cup-ingredients.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}]}, "finalUrl": "https://tenant-alpha-secondary.com"}'::jsonb, + 'https://tenant-alpha-secondary.com', + true, + false, + NULL, + '2025-02-26T12:09:01.180Z'::timestamptz, + '2025-02-26T12:09:01.181Z'::timestamptz, + '2025-02-26T12:09:01.183Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + 'faefae51-884b-4940-a53f-efe554a18616', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + 'alt-text', + '{"sourceS3Folder": "spacecat-scraper/scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "fullAuditRef": "scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "detectedTags": {"imagesWithoutAltText": [{"src": "/content/dam/tenant-alpha-chocolate-world/images/blog/our-sweet-history/storytelling-video-historicaltour.jpg", "pageUrl": "/blog/history.html"}, {"src": "/plan-your-visit/amenities-and-accessibility/_jcr_content/root/container/section_1887685911/col1/section/col2/image.coreimg.png/1645138368408/amenities-badge-cac.png", "pageUrl": "/plan-your-visit/amenities-and-accessibility.html"}, {"src": "/plan-your-visit/groups-and-parties/_jcr_content/root/container/section_410668149_co_292676950/col2/call_out_tile/image.img.jpg/1694802555244.jpg?im=Resize=(193)", "pageUrl": "/plan-your-visit/groups-and-parties.html"}, {"src": "/things-to-do/reeses-stuff-your-cup-ingredients/_jcr_content/root/container/section_article_over/col1/section_copy/col2/image.coreimg.png/1677778544157/reeses-syc-logo.png", "pageUrl": "/things-to-do/reeses-stuff-your-cup-ingredients.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}]}, "finalUrl": "https://tenant-alpha-secondary.com"}'::jsonb, + 'https://tenant-alpha-secondary.com', + true, + false, + NULL, + '2025-02-26T12:24:33.423Z'::timestamptz, + '2025-02-26T12:24:33.424Z'::timestamptz, + '2025-02-26T12:24:33.427Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '3af2e62a-38e9-49c8-9105-beb5e03f11ae', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + 'alt-text', + '{"sourceS3Folder": "spacecat-scraper/scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "fullAuditRef": "scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "detectedTags": {"imagesWithoutAltText": [{"src": "/content/dam/tenant-alpha-chocolate-world/images/blog/our-sweet-history/storytelling-video-historicaltour.jpg", "pageUrl": "/blog/history.html"}, {"src": "/plan-your-visit/amenities-and-accessibility/_jcr_content/root/container/section_1887685911/col1/section/col2/image.coreimg.png/1645138368408/amenities-badge-cac.png", "pageUrl": "/plan-your-visit/amenities-and-accessibility.html"}, {"src": "/plan-your-visit/groups-and-parties/_jcr_content/root/container/section_410668149_co_292676950/col2/call_out_tile/image.img.jpg/1694802555244.jpg?im=Resize=(193)", "pageUrl": "/plan-your-visit/groups-and-parties.html"}, {"src": "/things-to-do/reeses-stuff-your-cup-ingredients/_jcr_content/root/container/section_article_over/col1/section_copy/col2/image.coreimg.png/1677778544157/reeses-syc-logo.png", "pageUrl": "/things-to-do/reeses-stuff-your-cup-ingredients.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}]}, "finalUrl": "https://tenant-alpha-secondary.com"}'::jsonb, + 'https://tenant-alpha-secondary.com', + true, + false, + NULL, + '2025-02-26T12:33:33.631Z'::timestamptz, + '2025-02-26T12:33:33.632Z'::timestamptz, + '2025-02-26T12:33:33.635Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '48cdf221-25ae-41b1-805e-4bf326734995', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + 'alt-text', + '{"sourceS3Folder": "spacecat-scraper/scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "fullAuditRef": "scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "detectedTags": {"imagesWithoutAltText": [{"src": "/content/dam/tenant-alpha-chocolate-world/images/blog/our-sweet-history/storytelling-video-historicaltour.jpg", "pageUrl": "/blog/history.html"}, {"src": "/plan-your-visit/amenities-and-accessibility/_jcr_content/root/container/section_1887685911/col1/section/col2/image.coreimg.png/1645138368408/amenities-badge-cac.png", "pageUrl": "/plan-your-visit/amenities-and-accessibility.html"}, {"src": "/plan-your-visit/groups-and-parties/_jcr_content/root/container/section_410668149_co_292676950/col2/call_out_tile/image.img.jpg/1694802555244.jpg?im=Resize=(193)", "pageUrl": "/plan-your-visit/groups-and-parties.html"}, {"src": "/things-to-do/reeses-stuff-your-cup-ingredients/_jcr_content/root/container/section_article_over/col1/section_copy/col2/image.coreimg.png/1677778544157/reeses-syc-logo.png", "pageUrl": "/things-to-do/reeses-stuff-your-cup-ingredients.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}]}, "finalUrl": "https://tenant-alpha-secondary.com"}'::jsonb, + 'https://tenant-alpha-secondary.com', + true, + false, + NULL, + '2025-02-26T14:47:00.335Z'::timestamptz, + '2025-02-26T14:47:00.336Z'::timestamptz, + '2025-02-26T14:47:00.339Z'::timestamptz, + 'system' +); +INSERT INTO audits (id, site_id, audit_type, audit_result, full_audit_ref, is_live, is_error, invocation_id, audited_at, created_at, updated_at, updated_by) VALUES ( + '1e69d5b7-db32-43b9-8fb4-c5634041792a', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + 'alt-text', + '{"sourceS3Folder": "spacecat-scraper/scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "fullAuditRef": "scrapes/e12c091c-075b-4c94-aab7-398a04412b5c/", "detectedTags": {"imagesWithoutAltText": [{"src": "/content/dam/tenant-alpha-chocolate-world/images/blog/our-sweet-history/storytelling-video-historicaltour.jpg", "pageUrl": "/blog/history.html"}, {"src": "/plan-your-visit/amenities-and-accessibility/_jcr_content/root/container/section_1887685911/col1/section/col2/image.coreimg.png/1645138368408/amenities-badge-cac.png", "pageUrl": "/plan-your-visit/amenities-and-accessibility.html"}, {"src": "/plan-your-visit/groups-and-parties/_jcr_content/root/container/section_410668149_co_292676950/col2/call_out_tile/image.img.jpg/1694802555244.jpg?im=Resize=(193)", "pageUrl": "/plan-your-visit/groups-and-parties.html"}, {"src": "/things-to-do/reeses-stuff-your-cup-ingredients/_jcr_content/root/container/section_article_over/col1/section_copy/col2/image.coreimg.png/1677778544157/reeses-syc-logo.png", "pageUrl": "/things-to-do/reeses-stuff-your-cup-ingredients.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}, {"src": "/content/dam/tenant-alpha-chocolate-world/images/buy-tickets/buy-tickets-split-callout-meal-deal.jpg?im=Resize=(603)", "pageUrl": "/tickets.html"}]}, "finalUrl": "https://tenant-alpha-secondary.com"}'::jsonb, + 'https://tenant-alpha-secondary.com', + true, + false, + NULL, + '2025-02-26T15:51:57.343Z'::timestamptz, + '2025-02-26T15:51:57.344Z'::timestamptz, + '2025-02-26T15:51:57.347Z'::timestamptz, + 'system' +); + +-- Opportunities +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '698054e3-3fbd-4b1d-bca1-37c094217e82', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report Week 12 - 2025', + 'A web accessibility audit is an assessment of how well your website and digital assets conform to the needs of people with disabilities and if they follow the Web Content Accessibility Guidelines (WCAG).', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-03-13T15:27:12.562Z'::timestamptz, + '2025-03-13T15:27:12.563Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '8890223a-358c-4f61-97d7-41f72d74b854', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report Week 12 - 2025 - in-depth', + 'This report provides an in-depth overview of various accessibility issues identified across different web pages. It categorizes issues based on their severity and impact, offering detailed descriptions and recommended fixes. The report covers critical aspects such as ARIA attributes, keyboard navigation, and screen reader compatibility to ensure a more inclusive and accessible web experience for all users.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-03-14T11:52:52.699Z'::timestamptz, + '2025-03-14T11:52:52.701Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '7803bf52-5f7c-4407-a27c-b6f51f07c997', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Enhancing accessibility for the top 10 most-visited pages', + 'Here are some optimization suggestions that could help solve the accessibility issues found on the top 10 most-visited pages', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-03-14T12:07:35.441Z'::timestamptz, + '2025-03-14T12:07:35.442Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'b3ed2031-0d1a-4107-9f33-21df545ebd52', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report Week 13 - 2025 - in-depth', + 'This report provides an in-depth overview of various accessibility issues identified across different web pages. It categorizes issues based on their severity and impact, offering detailed descriptions and recommended fixes. The report covers critical aspects such as ARIA attributes, keyboard navigation, and screen reader compatibility to ensure a more inclusive and accessible web experience for all users.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-03-26T14:11:59.449Z'::timestamptz, + '2025-03-26T14:11:59.450Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '9de4cffb-5be1-4647-a169-3b71b31aea83', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Enhancing accessibility for the top 10 most-visited pages - Desktop - Week 13 - 2025', + 'Here are some optimization suggestions that could help solve the accessibility issues found on the top 10 most-visited pages.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-03-26T14:26:23.287Z'::timestamptz, + '2025-03-26T14:44:07.094Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '6486da2a-2327-4f14-b75e-4ec16d7bdc82', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 13 - 2025', + 'A web accessibility audit is an assessment of how well your website and digital assets conform to the needs of people with disabilities and if they follow the Web Content Accessibility Guidelines (WCAG). Desktop only version.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-03-26T14:10:17.608Z'::timestamptz, + '2025-03-26T14:46:00.655Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'fd61d378-fccf-4f13-a47e-067e74bf7f27', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Enhancing accessibility for the top 10 most-visited pages -Desktop - Week 14', + 'Here are some optimization suggestions that could help solve the accessibility issues found on the top 10 most-visited pages.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-02T12:34:21.373Z'::timestamptz, + '2025-04-02T12:34:21.374Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '0c9e2e1f-c2b2-45df-8e66-eb0a18ea781c', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 14 - 2025 - in-depth', + 'This report provides an in-depth overview of various accessibility issues identified across different web pages. It categorizes issues based on their severity and impact, offering detailed descriptions and recommended fixes. The report covers critical aspects such as ARIA attributes, keyboard navigation, and screen reader compatibility to ensure a more inclusive and accessible web experience for all users.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-02T12:34:49.762Z'::timestamptz, + '2025-04-02T12:34:49.762Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '5f257522-2b5a-4b15-8398-d26313aaa963', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 15 - 2025 - in-depth', + 'This report provides an in-depth overview of various accessibility issues identified across different web pages. It categorizes issues based on their severity and impact, offering detailed descriptions and recommended fixes. The report covers critical aspects such as ARIA attributes, keyboard navigation, and screen reader compatibility to ensure a more inclusive and accessible web experience for all users.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-08T14:27:52.179Z'::timestamptz, + '2025-04-08T14:27:52.180Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '0be4cab5-ee72-428f-b936-ef213c31d2c2', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Enhancing accessibility for the top 10 most-visited pages - Desktop - Week 15 - 2025', + 'Here are some optimization suggestions that could help solve the accessibility issues found on the top 10 most-visited pages.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-08T14:28:07.846Z'::timestamptz, + '2025-04-08T14:28:07.848Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'd214f3b9-619a-4a0a-ab12-dd5a41d70e62', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 16 - 2025 - in-depth', + 'This report provides an in-depth overview of various accessibility issues identified across different web pages. It categorizes issues based on their severity and impact, offering detailed descriptions and recommended fixes. The report covers critical aspects such as ARIA attributes, keyboard navigation, and screen reader compatibility to ensure a more inclusive and accessible web experience for all users.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-15T14:49:57.793Z'::timestamptz, + '2025-04-15T14:49:57.794Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '879ed275-54ff-4910-9dda-5b12d4531852', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report Fixed vs New Issues - Desktop - Week 16 - 2025', + 'This report provides a comprehensive analysis of accessibility issues, highlighting both resolved and newly identified problems. It aims to track progress in improving accessibility and identify areas requiring further attention.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-15T14:50:07.315Z'::timestamptz, + '2025-04-15T14:50:07.317Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'f116a366-b8af-46bd-b711-4f70e2d8b2f1', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Enhancing accessibility for the top 10 most-visited pages - Desktop - Week 16 - 2025', + 'Here are some optimization suggestions that could help solve the accessibility issues found on the top 10 most-visited pages.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-15T14:50:18.912Z'::timestamptz, + '2025-04-15T14:50:18.913Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '4dd4039b-65c4-4e03-9209-e3f869a565d8', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 14 - 2025', + 'A web accessibility audit is an assessment of how well your website and digital assets conform to the needs of people with disabilities and if they follow the Web Content Accessibility Guidelines (WCAG). Desktop only.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-02T12:32:57.059Z'::timestamptz, + '2025-04-17T12:44:51.120Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '05a3b8bf-7513-4332-a77c-e36ca638fe03', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 15 - 2025', + 'A web accessibility audit is an assessment of how well your website and digital assets conform to the needs of people with disabilities and if they follow the Web Content Accessibility Guidelines (WCAG). Desktop only.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-08T14:29:34.916Z'::timestamptz, + '2025-04-17T12:44:59.571Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '0b10d7eb-ff98-4298-b11d-39811de73be1', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 17 - 2025 - in-depth', + 'This report provides an in-depth overview of various accessibility issues identified across different web pages. It categorizes issues based on their severity and impact, offering detailed descriptions and recommended fixes. The report covers critical aspects such as ARIA attributes, keyboard navigation, and screen reader compatibility to ensure a more inclusive and accessible web experience for all users.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-23T14:55:04.478Z'::timestamptz, + '2025-04-23T14:55:04.480Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '225e5ab1-60e5-4c27-a92c-e07a215b512d', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report Fixed vs New Issues - Desktop - Week 17 - 2025', + 'This report provides a comprehensive analysis of accessibility issues, highlighting both resolved and newly identified problems. It aims to track progress in improving accessibility and identify areas requiring further attention.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-23T14:55:12.915Z'::timestamptz, + '2025-04-23T14:55:12.916Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'c3bec7ea-7c6b-47b5-88f3-6b67af8ee60a', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Enhancing accessibility for the top 10 most-visited pages - Desktop - Week 17 - 2025', + 'Here are some optimization suggestions that could help solve the accessibility issues found on the top 10 most-visited pages.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-23T14:55:22.169Z'::timestamptz, + '2025-04-23T14:55:22.169Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '22aad080-3d1b-4dc0-b45e-ad4268490e5c', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 16 - 2025', + 'A web accessibility audit is an assessment of how well your website and digital assets conform to the needs of people with disabilities and if they follow the Web Content Accessibility Guidelines (WCAG). Desktop only.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-15T14:51:15.984Z'::timestamptz, + '2025-04-23T14:56:33.687Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '6ef2b19d-d4e8-4f18-b242-f29bd767ba82', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 18 - 2025 - in-depth', + 'This report provides an in-depth overview of various accessibility issues identified across different web pages. It categorizes issues based on their severity and impact, offering detailed descriptions and recommended fixes. The report covers critical aspects such as ARIA attributes, keyboard navigation, and screen reader compatibility to ensure a more inclusive and accessible web experience for all users.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-30T14:56:48.097Z'::timestamptz, + '2025-04-30T14:56:48.099Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'ec9aaffd-97da-47e7-a5d9-faf8dc044b23', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report Fixed vs New Issues - Desktop - Week 18 - 2025', + 'This report provides a comprehensive analysis of accessibility issues, highlighting both resolved and newly identified problems. It aims to track progress in improving accessibility and identify areas requiring further attention.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-30T14:57:06.328Z'::timestamptz, + '2025-04-30T14:57:06.328Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '95088985-4db9-4d6d-8505-eb8ba6a5f404', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Enhancing accessibility for the top 10 most-visited pages - Desktop - Week 18 - 2025', + 'Here are some optimization suggestions that could help solve the accessibility issues found on the top 10 most-visited pages.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-30T14:57:18.091Z'::timestamptz, + '2025-04-30T14:57:18.092Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'a8fe5fd4-9486-449e-85b4-5812227ddcaa', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 17 - 2025', + 'A web accessibility audit is an assessment of how well your website and digital assets conform to the needs of people with disabilities and if they follow the Web Content Accessibility Guidelines (WCAG). Desktop only.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-23T14:56:22.193Z'::timestamptz, + '2025-04-30T14:59:29.512Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'd734deeb-1b96-4656-8391-d1eae650bc15', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 18 - 2025', + 'A web accessibility audit is an assessment of how well your website and digital assets conform to the needs of people with disabilities and if they follow the Web Content Accessibility Guidelines (WCAG). Desktop only.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-04-30T14:58:30.262Z'::timestamptz, + '2025-05-09T11:38:49.368Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'd4754c8c-ef65-476d-a074-d67dd0512c0d', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 19 - 2025 - in-depth', + 'This report provides an in-depth overview of various accessibility issues identified across different web pages. It categorizes issues based on their severity and impact, offering detailed descriptions and recommended fixes. The report covers critical aspects such as ARIA attributes, keyboard navigation, and screen reader compatibility to ensure a more inclusive and accessible web experience for all users.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-05-09T11:38:57.241Z'::timestamptz, + '2025-05-09T11:38:57.243Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'faabb5f9-37db-4671-8de9-15e989ffa9e9', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report Fixed vs New Issues - Desktop - Week 19 - 2025', + 'This report provides a comprehensive analysis of accessibility issues, highlighting both resolved and newly identified problems. It aims to track progress in improving accessibility and identify areas requiring further attention.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-05-09T11:39:10.908Z'::timestamptz, + '2025-05-09T11:39:10.910Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '0448b8bb-34eb-4ed6-b963-989339b667b4', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + NULL::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Enhancing accessibility for the top 10 most-visited pages - Desktop - Week 19 - 2025', + 'Here are some optimization suggestions that could help solve the accessibility issues found on the top 10 most-visited pages.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-05-09T11:39:23.674Z'::timestamptz, + '2025-05-09T11:39:23.675Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'c5470bcd-1844-442c-bd1e-8d87fd60c95b', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + '{"dataSources": ["Ahrefs"]}'::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 19 - 2025', + 'A web accessibility audit is an assessment of how well your website and digital assets conform to the needs of people with disabilities and if they follow the Web Content Accessibility Guidelines (WCAG). Desktop only.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-05-09T11:40:26.439Z'::timestamptz, + '2025-05-16T12:49:57.843Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '6f3aceb8-bf54-4cae-9fff-d72ea8fe715c', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + '{"dataSources": ["Ahrefs", "GSC", "Site", "RUM"]}'::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report - Desktop - Week 20 - 2025 - in-depth', + 'This report provides an in-depth overview of various accessibility issues identified across different web pages. It categorizes issues based on their severity and impact, offering detailed descriptions and recommended fixes. The report covers critical aspects such as ARIA attributes, keyboard navigation, and screen reader compatibility to ensure a more inclusive and accessible web experience for all users.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-05-16T12:50:17.738Z'::timestamptz, + '2025-05-16T12:50:17.739Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + '9e8a6770-bc19-4222-b931-072b1471b520', + '0983c6da-0dee-45cc-b897-3f1fed6b460b', + NULL, + 'https://adobe.sharepoint.com/:w:/r/sites/aemsites-engineering/Shared%20Documents/3%20-%20Experience%20Success/SpaceCat/Runbooks/Experience_Success_Studio_Runbook_Template.docx?d=w5ec0880fdc7a41c786c7409157f5de48&csf=1&web=1&e=vXnRVq', + 'generic-opportunity', + '{"dataSources": ["Ahrefs", "GSC", "Site", "RUM"]}'::jsonb, + 'ESS_OPS'::opportunity_origin, + 'Accessibility report Fixed vs New Issues - Desktop - Week 20 - 2025', + 'This report provides a comprehensive analysis of accessibility issues, highlighting both resolved and newly identified problems. It aims to track progress in improving accessibility and identify areas requiring further attention.', + 'IGNORED'::opportunity_status, + NULL::jsonb, + ARRAY['a11y']::text[], + '2025-05-16T12:50:30.499Z'::timestamptz, + '2025-05-16T12:50:30.500Z'::timestamptz, + 'system' +); +INSERT INTO opportunities (id, site_id, audit_id, runbook, type, data, origin, title, description, status, guidance, tags, created_at, updated_at, updated_by) VALUES ( + 'ab889047-061d-421f-9e5f-1019c940bdc9', + 'e12c091c-075b-4c94-aab7-398a04412b5c', + '4c081ef7-f4f8-4ae0-828c-98f145d16be2', + 'https://adobe.sharepoint.com/:w:/s/AEM_Forms/Ebpoflp2gHFNl4w5-9C7dFEBBHHE4gTaRzHaofqSxJMuuQ?e=Ss6mep', + 'form-accessibility', + '{"accessibility": [{"form": "https://www.tenant-alpha-secondary.com/plan-your-visit/birthday-parties.html", "formSource": "#birthday-form form#groupForm", "a11yIssues": [{"htmlWithIssues": ["", "", ""], "recommendation": "Fix any of the following:\n Element does not have an implicit (wrapped)