From e9a6310dbdea4d44562432b794aa1e287ba9428d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominique=20J=C3=A4ggi?= <1872195+solaris007@users.noreply.github.com> Date: Wed, 18 Dec 2024 10:20:23 +0100 Subject: [PATCH 1/8] feat: migrate entities to electrodb (#484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR significantly enhances the framework built on top of ElectroDB introduced via https://github.com/adobe/spacecat-shared/pull/447, providing a standardized approach to defining entities, schemas, models, and collections. It enforces schema consistency, automatically generates query methods, and supports relationship management—including cascading removals using transactional deletes. In addition, this PR migrates all currently available schemas, models, and collections from our legacy data layer into this new framework. --------- Co-authored-by: rublea Co-authored-by: Bruce Lefebvre Co-authored-by: Ekrem Doğan Co-authored-by: semantic-release-bot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs/schema.json | 184 ++- .../spacecat-shared-data-access/package.json | 2 +- .../src/models/site/config.js | 2 +- .../src/service/audits/accessPatterns.js | 14 +- .../src/service/experiments/accessPatterns.js | 4 +- .../src/service/import-job/accessPatterns.js | 2 +- .../src/service/import-url/accessPatterns.js | 4 +- .../src/service/index.js | 28 +- .../src/service/key-events/accessPatterns.js | 6 +- .../service/organizations/accessPatterns.js | 6 +- .../service/site-candidates/accessPatterns.js | 2 +- .../src/service/sites/accessPatterns.js | 22 +- .../v2/models/api-key/api-key.collection.js | 26 + .../src/v2/models/api-key/api-key.model.js | 59 + .../src/v2/models/api-key/api-key.schema.js | 82 + .../src/v2/models/api-key/index.d.ts | 37 + .../src/v2/models/api-key/index.js | 19 + .../src/v2/models/audit/audit.collection.js | 26 + .../src/v2/models/audit/audit.model.js | 89 ++ .../src/v2/models/audit/audit.schema.js | 66 + .../src/v2/models/audit/index.d.ts | 40 + .../src/v2/models/audit/index.js | 19 + .../src/v2/models/base.collection.js | 275 ---- .../src/v2/models/base/base.collection.js | 450 ++++++ .../src/v2/models/{ => base}/base.model.js | 198 +-- .../src/v2/models/base/constants.js | 17 + .../src/v2/models/base/entity.registry.js | 137 ++ .../src/v2/models/base/index.d.ts | 83 + .../src/v2/models/base/index.js | 27 + .../src/v2/models/base/reference.js | 159 ++ .../src/v2/models/base/schema.builder.js | 420 +++++ .../src/v2/models/base/schema.js | 283 ++++ .../configuration/configuration.collection.js | 39 + .../configuration/configuration.model.js | 160 ++ .../configuration/configuration.schema.js | 103 ++ .../src/v2/models/configuration/index.d.ts | 111 ++ .../src/v2/models/configuration/index.js | 19 + .../experiment/experiment.collection.js | 26 + .../v2/models/experiment/experiment.model.js | 28 + .../v2/models/experiment/experiment.schema.js | 70 + .../src/v2/models/experiment/index.d.ts | 49 + .../src/v2/models/experiment/index.js | 19 + .../import-job/import-job.collection.js | 45 + .../v2/models/import-job/import-job.model.js | 55 + .../v2/models/import-job/import-job.schema.js | 152 ++ .../src/v2/models/import-job/index.d.ts | 51 + .../src/v2/models/import-job/index.js | 19 + .../import-url/import-url.collection.js | 26 + .../v2/models/import-url/import-url.model.js | 28 + .../v2/models/import-url/import-url.schema.js | 59 + .../src/v2/models/import-url/index.d.ts | 35 + .../src/v2/models/import-url/index.js | 19 + .../src/v2/models/index.d.ts | 110 +- .../src/v2/models/index.js | 29 +- .../src/v2/models/key-event/index.d.ts | 28 + .../src/v2/models/key-event/index.js | 19 + .../models/key-event/key-event.collection.js | 26 + .../v2/models/key-event/key-event.model.js | 37 + .../v2/models/key-event/key-event.schema.js | 45 + .../src/v2/models/model.factory.js | 74 - .../src/v2/models/opportunity.collection.js | 74 - .../src/v2/models/opportunity/index.d.ts | 46 + .../src/v2/models/opportunity/index.js | 19 + .../opportunity/opportunity.collection.js | 26 + .../{ => opportunity}/opportunity.model.js | 17 +- .../models/opportunity/opportunity.schema.js | 69 + .../src/v2/models/organization/index.d.ts | 28 + .../src/v2/models/organization/index.js | 19 + .../organization/organization.collection.js | 26 + .../models/organization/organization.model.js | 31 + .../organization/organization.schema.js | 51 + .../src/v2/models/site-candidate/index.d.ts | 38 + .../src/v2/models/site-candidate/index.js | 19 + .../site-candidate.collection.js | 27 + .../site-candidate/site-candidate.model.js | 41 + .../site-candidate/site-candidate.schema.js | 59 + .../src/v2/models/site-top-page/index.d.ts | 35 + .../src/v2/models/site-top-page/index.js | 19 + .../site-top-page/site-top-page.collection.js | 44 + .../site-top-page/site-top-page.model.js | 28 + .../site-top-page/site-top-page.schema.js | 65 + .../src/v2/models/site/index.d.ts | 43 + .../src/v2/models/site/index.js | 20 + .../src/v2/models/site/site.collection.js | 28 + .../src/v2/models/site/site.model.js | 47 + .../src/v2/models/site/site.schema.js | 91 ++ .../src/v2/models/suggestion.collection.js | 104 -- .../src/v2/models/suggestion/index.d.ts | 34 + .../src/v2/models/suggestion/index.js | 19 + .../suggestion/suggestion.collection.js | 55 + .../{ => suggestion}/suggestion.model.js | 17 +- .../v2/models/suggestion/suggestion.schema.js | 53 + .../src/v2/readme.md | 457 +++--- .../src/v2/schema/opportunity.schema.js | 159 -- .../src/v2/schema/suggestion.schema.js | 132 -- .../src/v2/util/accessor.utils.js | 158 ++ .../src/v2/util/guards.d.ts | 7 + .../src/v2/util/guards.js | 25 +- .../src/v2/util/index.js | 1 + .../src/v2/util/patcher.js | 79 +- .../src/v2/util/reference.js | 41 - .../src/v2/util/util.js | 84 + .../test/fixtures/api-keys.fixtures.js | 58 + .../test/fixtures/audits.fixture.js | 1411 +++++++++++++++++ .../test/fixtures/configurations.fixture.js | 111 ++ .../test/fixtures/experiments.fixture.js | 136 ++ .../test/fixtures/import-jobs.fixture.js | 67 + .../test/fixtures/import-urls.fixture.js | 79 + .../test/fixtures/index.fixtures.js | 41 + .../test/fixtures/key-events.fixture.js | 716 +++++++++ .../test/fixtures/opportunities.fixture.js | 73 + .../test/fixtures/organizations.fixture.js | 106 ++ .../test/fixtures/site-candidates.fixture.js | 76 + .../test/fixtures/site-top-pages.fixture.js | 516 ++++++ .../test/fixtures/sites.fixture.js | 424 +++++ .../test/fixtures/suggestions.fixture.js | 115 ++ .../test/it/api-key/api-key.test.js | 137 ++ .../test/it/audit/audit.test.js | 100 ++ .../it/configuration/configuration.test.js | 118 ++ .../test/it/experiment/experiment.test.js | 167 ++ .../test/it/fixtures.js | 84 + .../test/it/import-job/import-job.test.js | 165 ++ .../test/it/import-url/import-url.test.js | 131 ++ .../test/it/index.test.js | 84 +- .../test/it/key-events/key-events.test.js | 99 ++ .../test/it/opportunity/opportunity.test.js | 320 ++++ .../test/it/organization/organization.test.js | 143 ++ .../it/site-candidate/site-candidate.test.js | 105 ++ .../it/site-top-page/site-top-page.test.js | 172 ++ .../test/it/site/site.test.js | 281 ++++ .../test/it/suggestion/suggestion.test.js | 248 +++ .../test/it/util/db.js | 102 +- ...pleData.js => generateLegacySampleData.js} | 52 +- .../test/it/util/seed.js | 80 + .../test/it/util/tableOperations.js | 40 +- .../test/it/util/util.js | 47 +- .../test/it/v2/index.test.js | 545 ------- .../test/unit/index.test.js | 6 +- .../test/unit/service/index.test.js | 11 + .../models/api-key/api-key.collection.test.js | 61 + .../v2/models/api-key/api-key.model.test.js | 197 +++ .../v2/models/audit/audit.collection.test.js | 61 + .../unit/v2/models/audit/audit.model.test.js | 185 +++ .../unit/v2/models/base.collection.test.js | 316 ---- .../test/unit/v2/models/base.model.test.js | 179 --- .../v2/models/base/base.collection.test.js | 651 ++++++++ .../unit/v2/models/base/base.model.test.js | 309 ++++ .../v2/models/base/entity.registry.test.js | 114 ++ .../unit/v2/models/base/reference.test.js | 312 ++++ .../v2/models/base/schema.builder.test.js | 475 ++++++ .../test/unit/v2/models/base/schema.test.js | 229 +++ .../configuration.collection.test.js | 107 ++ .../configuration/configuration.model.test.js | 211 +++ .../experiment/experiment.collection.test.js | 61 + .../experiment/experiment.model.test.js | 203 +++ .../import-job/import-job.collection.test.js | 94 ++ .../import-job/import-job.model.test.js | 256 +++ .../import-url/import-url.collection.test.js | 61 + .../import-url/import-url.model.test.js | 141 ++ .../key-event/key-event.collection.test.js | 61 + .../models/key-event/key-event.model.test.js | 106 ++ .../test/unit/v2/models/model.factory.test.js | 68 - .../v2/models/opportunity.collection.test.js | 154 -- .../unit/v2/models/opportunity.model.test.js | 227 --- .../opportunity.collection.test.js | 75 + .../opportunity/opportunity.model.test.js | 196 +++ .../organization.collection.test.js | 61 + .../organization/organization.model.test.js | 100 ++ .../site-candidate.collection.test.js | 61 + .../site-top-page.collection.test.js | 93 ++ .../v2/models/site/site.collection.test.js | 73 + .../unit/v2/models/site/site.model.test.js | 178 +++ .../v2/models/suggestion.collection.test.js | 197 --- .../unit/v2/models/suggestion.model.test.js | 156 -- .../suggestion/suggestion.collection.test.js | 96 ++ .../suggestion/suggestion.model.test.js | 122 ++ .../test/unit/v2/util.js | 107 ++ .../test/unit/v2/util/accessor.utils.test.js | 222 +++ .../test/unit/v2/util/guards.test.js | 48 +- .../test/unit/v2/util/patcher.test.js | 76 +- .../test/unit/v2/util/util.test.js | 220 +++ .../src/auth/authentication-manager.js | 2 +- .../test/auth/handlers/abstract.test.js | 2 +- .../test/auth/handlers/ims.test.js | 2 +- .../auth/handlers/legacy-api-keys.test.js | 2 +- .../test/auth/handlers/scoped-api-key.test.js | 2 +- packages/spacecat-shared-ims-client/README.md | 4 +- .../src/clients/base-slack-client.js | 2 +- .../src/clients/elevated-slack-client.js | 2 +- 189 files changed, 17302 insertions(+), 3506 deletions(-) mode change 100644 => 100755 packages/spacecat-shared-data-access/src/service/index.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.collection.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.model.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/api-key/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/api-key/index.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/audit/audit.collection.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/audit/audit.model.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/audit/audit.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/audit/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/audit/index.js delete mode 100755 packages/spacecat-shared-data-access/src/v2/models/base.collection.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js rename packages/spacecat-shared-data-access/src/v2/models/{ => base}/base.model.js (51%) create mode 100644 packages/spacecat-shared-data-access/src/v2/models/base/constants.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/base/entity.registry.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/base/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/base/index.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/base/reference.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/base/schema.builder.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/base/schema.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.collection.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.model.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/configuration/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/configuration/index.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.collection.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.model.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/experiment/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/experiment/index.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.collection.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.model.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/import-job/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/import-job/index.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.collection.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.model.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/import-url/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/import-url/index.js mode change 100644 => 100755 packages/spacecat-shared-data-access/src/v2/models/index.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/key-event/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/key-event/index.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.collection.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.model.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.schema.js delete mode 100644 packages/spacecat-shared-data-access/src/v2/models/model.factory.js delete mode 100644 packages/spacecat-shared-data-access/src/v2/models/opportunity.collection.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/opportunity/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/opportunity/index.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.collection.js rename packages/spacecat-shared-data-access/src/v2/models/{ => opportunity}/opportunity.model.js (85%) create mode 100644 packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/organization/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/organization/index.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/organization/organization.collection.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/organization/organization.model.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/organization/organization.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.collection.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.model.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.collection.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.model.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/site/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/site/index.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/site/site.collection.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/site/site.model.js create mode 100755 packages/spacecat-shared-data-access/src/v2/models/site/site.schema.js delete mode 100644 packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/suggestion/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/v2/models/suggestion/index.js create mode 100644 packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.collection.js rename packages/spacecat-shared-data-access/src/v2/models/{ => suggestion}/suggestion.model.js (73%) create mode 100644 packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.schema.js mode change 100644 => 100755 packages/spacecat-shared-data-access/src/v2/readme.md delete mode 100644 packages/spacecat-shared-data-access/src/v2/schema/opportunity.schema.js delete mode 100644 packages/spacecat-shared-data-access/src/v2/schema/suggestion.schema.js create mode 100644 packages/spacecat-shared-data-access/src/v2/util/accessor.utils.js mode change 100644 => 100755 packages/spacecat-shared-data-access/src/v2/util/guards.d.ts mode change 100644 => 100755 packages/spacecat-shared-data-access/src/v2/util/patcher.js delete mode 100644 packages/spacecat-shared-data-access/src/v2/util/reference.js create mode 100644 packages/spacecat-shared-data-access/src/v2/util/util.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/api-keys.fixtures.js create mode 100755 packages/spacecat-shared-data-access/test/fixtures/audits.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js create mode 100755 packages/spacecat-shared-data-access/test/fixtures/experiments.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/import-jobs.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/import-urls.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js create mode 100755 packages/spacecat-shared-data-access/test/fixtures/key-events.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/opportunities.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/organizations.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/site-candidates.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/site-top-pages.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/fixtures/sites.fixture.js create mode 100755 packages/spacecat-shared-data-access/test/fixtures/suggestions.fixture.js create mode 100644 packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/audit/audit.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/fixtures.js create mode 100644 packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js mode change 100644 => 100755 packages/spacecat-shared-data-access/test/it/index.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/organization/organization.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/site-candidate/site-candidate.test.js create mode 100755 packages/spacecat-shared-data-access/test/it/site-top-page/site-top-page.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/site/site.test.js create mode 100644 packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js rename packages/spacecat-shared-data-access/test/it/util/{generateSampleData.js => generateLegacySampleData.js} (85%) create mode 100644 packages/spacecat-shared-data-access/test/it/util/seed.js mode change 100644 => 100755 packages/spacecat-shared-data-access/test/it/util/tableOperations.js mode change 100644 => 100755 packages/spacecat-shared-data-access/test/it/util/util.js delete mode 100755 packages/spacecat-shared-data-access/test/it/v2/index.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.model.test.js delete mode 100644 packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js delete mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/base.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/base/base.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/base/base.model.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/v2/models/base/entity.registry.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/v2/models/base/reference.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.builder.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.model.test.js delete mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/model.factory.test.js delete mode 100644 packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.collection.test.js delete mode 100644 packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/site-candidate/site-candidate.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/site-top-page/site-top-page.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/site/site.collection.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/site/site.model.test.js delete mode 100644 packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js delete mode 100644 packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.collection.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.model.test.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/util.js create mode 100755 packages/spacecat-shared-data-access/test/unit/v2/util/accessor.utils.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/v2/util/util.test.js diff --git a/packages/spacecat-shared-data-access/docs/schema.json b/packages/spacecat-shared-data-access/docs/schema.json index 03833dc0..4a6a1e7e 100644 --- a/packages/spacecat-shared-data-access/docs/schema.json +++ b/packages/spacecat-shared-data-access/docs/schema.json @@ -40,68 +40,156 @@ ], "GlobalSecondaryIndexes": [ { - "IndexName": "spacecat-data-opportunity-by-site", + "IndexName": "spacecat-data-ApiKey-byHashedApiKey", "KeyAttributes": { - "PartitionKey": { - "AttributeName": "gsi1pk", - "AttributeType": "S" - }, - "SortKey": { - "AttributeName": "gsi1sk", - "AttributeType": "S" - } + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } }, - "Projection": { - "ProjectionType": "ALL" - } + "Projection": { "ProjectionType": "ALL" } }, { - "IndexName": "spacecat-data-opportunity-by-site-and-status", + "IndexName": "spacecat-data-ApiKey-byImsOrgIdAndImsUserId", "KeyAttributes": { - "PartitionKey": { - "AttributeName": "gsi2pk", - "AttributeType": "S" - }, - "SortKey": { - "AttributeName": "gsi2sk", - "AttributeType": "S" - } + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } }, - "Projection": { - "ProjectionType": "ALL" - } + "Projection": { "ProjectionType": "ALL" } }, { - "IndexName": "spacecat-data-suggestion-by-opportunity", + "IndexName": "spacecat-data-Opportunity-byAuditId", "KeyAttributes": { - "PartitionKey": { - "AttributeName": "gsi1pk", - "AttributeType": "S" - }, - "SortKey": { - "AttributeName": "gsi1sk", - "AttributeType": "S" - } + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } }, - "Projection": { - "ProjectionType": "ALL" - } + "Projection": { "ProjectionType": "ALL" } }, { - "IndexName": "spacecat-data-suggestion-by-opportunity-and-status", + "IndexName": "spacecat-data-Opportunity-bySiteId", "KeyAttributes": { - "PartitionKey": { - "AttributeName": "gsi2pk", - "AttributeType": "S" - }, - "SortKey": { - "AttributeName": "gsi2sk", - "AttributeType": "S" - } + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } }, - "Projection": { - "ProjectionType": "ALL" - } + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Suggestion-byOpportunityId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Site-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Site-byOrganizationId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Site-byDeliveryType", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi3pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi3sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Organization-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Audit-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Experiment-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-KeyEvent-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-SiteCandidate-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-SiteCandidate-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-SiteTopPage-bySiteId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-Configuration-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "version", "AttributeType": "N" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-ImportJob-all", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-ImportJob-byStatus", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi2pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi2sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } + }, + { + "IndexName": "spacecat-data-ImportUrl-byImportJobId", + "KeyAttributes": { + "PartitionKey": { "AttributeName": "gsi1pk", "AttributeType": "S" }, + "SortKey": { "AttributeName": "gsi1sk", "AttributeType": "S" } + }, + "Projection": { "ProjectionType": "ALL" } } ] }, diff --git a/packages/spacecat-shared-data-access/package.json b/packages/spacecat-shared-data-access/package.json index 8f323730..b4aebdba 100644 --- a/packages/spacecat-shared-data-access/package.json +++ b/packages/spacecat-shared-data-access/package.json @@ -10,7 +10,7 @@ "main": "src/index.js", "types": "src/index.d.ts", "scripts": { - "test:it": "mocha --spec \"test/it/**/*.test.js\"", + "test:it": "mocha --require ./test/it/fixtures.js --spec \"test/it/**/*.test.js\"", "test": "c8 mocha --spec \"test/unit/**/*.test.js\"", "lint": "eslint .", "clean": "rm -rf package-lock.json node_modules" diff --git a/packages/spacecat-shared-data-access/src/models/site/config.js b/packages/spacecat-shared-data-access/src/models/site/config.js index 08498295..89a30add 100644 --- a/packages/spacecat-shared-data-access/src/models/site/config.js +++ b/packages/spacecat-shared-data-access/src/models/site/config.js @@ -45,7 +45,7 @@ export const DEFAULT_CONFIG = { }; // Function to validate incoming configuration -function validateConfiguration(config) { +export function validateConfiguration(config) { const { error, value } = configSchema.validate(config); if (error) { diff --git a/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js index 2936f756..0de981b1 100644 --- a/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js @@ -21,7 +21,7 @@ import { createAudit } from '../../models/audit.js'; * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which audits are being retrieved. * @param {string} [auditType] - Optional. The type of audits to retrieve. * @param {boolean} [ascending] - Optional. Determines if the audits should be sorted @@ -61,7 +61,7 @@ export const getAuditsForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which to retrieve the audit. * @param {string} auditType - The type of audit to retrieve. * @param auditedAt - The ISO 8601 timestamp of the audit. @@ -88,7 +88,7 @@ export const getAuditForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} auditType - The type of audits to retrieve. * @param {boolean} ascending - Determines if the audits should be sorted ascending * or descending by scores. @@ -121,7 +121,7 @@ export const getLatestAudits = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which audits are being retrieved. * @returns {Promise[]>} A promise that resolves to an array of latest audits * for the specified site. @@ -148,7 +148,7 @@ export const getLatestAuditsForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which the latest audit is being retrieved. * @param {string} auditType - The type of audit to retrieve the latest instance of. * @returns {Promise} A promise that resolves to the latest audit of the @@ -174,7 +174,7 @@ export const getLatestAuditForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {object} auditData - The audit data. * @returns {Promise>} */ @@ -261,7 +261,7 @@ async function removeAudits( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which audits are being removed. * @returns {Promise} */ diff --git a/packages/spacecat-shared-data-access/src/service/experiments/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/experiments/accessPatterns.js index b44d224f..cbf89620 100644 --- a/packages/spacecat-shared-data-access/src/service/experiments/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/experiments/accessPatterns.js @@ -44,7 +44,7 @@ export const getExperiment = async (dynamoClient, config, siteId, experimentId, * Retrieves all experiments for a given siteId. * @param {*} dynamoClient - The DynamoDB client. * @param {*} config - The data access config. - * @param {*} log - the logger object + * @param {*} log - the log object * @param {*} siteId - siteId of the experiment. * @param {*} experimentId - experiment id. * @returns {Promise>} A promise that resolves to @@ -77,7 +77,7 @@ export const getExperiments = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {object} log - the logger object + * @param {object} log - the log object * @param {object} experimentData - The experiment data. * @returns {Promise>} A promise that resolves to newly created/updated * experiment diff --git a/packages/spacecat-shared-data-access/src/service/import-job/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/import-job/accessPatterns.js index 9185c3f2..37df5af5 100644 --- a/packages/spacecat-shared-data-access/src/service/import-job/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/import-job/accessPatterns.js @@ -118,7 +118,7 @@ export const updateImportJob = async (dynamoClient, config, log, importJob) => { * Removes an Import Job and all associated URLs. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {ImportJob} importJob - The import job to remove. * @return {Promise} A promise that resolves when the import job has been removed. */ diff --git a/packages/spacecat-shared-data-access/src/service/import-url/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/import-url/accessPatterns.js index c3d866b0..844023df 100644 --- a/packages/spacecat-shared-data-access/src/service/import-url/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/import-url/accessPatterns.js @@ -101,7 +101,7 @@ export const getImportUrlsByJobIdAndStatus = async (dynamoClient, config, log, j * Get Import Urls by Job ID, if no urls exist an empty array is returned. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} jobId - The ID of the import job. * @returns {Promise} */ @@ -138,7 +138,7 @@ async function removeUrls(dynamoClient, config, urls) { * Remove all URLs associated with an import job. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} jobId - The ID of the import job. * @return {Promise} A promise that resolves when all URLs have been removed. */ diff --git a/packages/spacecat-shared-data-access/src/service/index.js b/packages/spacecat-shared-data-access/src/service/index.js old mode 100644 new mode 100755 index d3f3cb48..88a293e9 --- a/packages/spacecat-shared-data-access/src/service/index.js +++ b/packages/spacecat-shared-data-access/src/service/index.js @@ -13,14 +13,11 @@ import { createClient } from '@adobe/spacecat-shared-dynamo'; import { DynamoDB } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'; + import AWSXray from 'aws-xray-sdk'; import { Service } from 'electrodb'; -import ModelFactory from '../v2/models/model.factory.js'; -import OpportunityCollection from '../v2/models/opportunity.collection.js'; -import SuggestionCollection from '../v2/models/suggestion.collection.js'; -import OpportunitySchema from '../v2/schema/opportunity.schema.js'; -import SuggestionSchema from '../v2/schema/suggestion.schema.js'; +import EntityRegistry from '../v2/models/base/entity.registry.js'; import { auditFunctions } from './audits/index.js'; import { keyEventFunctions } from './key-events/index.js'; @@ -51,11 +48,9 @@ const createElectroService = (client, config, log) => { log.debug(JSON.stringify(event, null, 4)); }; /* c8 ignore end */ + return new Service( - { - opportunity: OpportunitySchema, - suggestion: SuggestionSchema, - }, + EntityRegistry.getEntities(), { client, table, @@ -74,11 +69,11 @@ const createElectroService = (client, config, log) => { * tableNameImportJobs: string, pkAllImportJobs: string, indexNameAllImportJobs: string, * tableNameSiteTopPages: string, indexNameAllOrganizations: string, * indexNameAllOrganizationsByImsOrgId: string, pkAllOrganizations: string}} config configuration - * @param {Logger} log logger + * @param {Logger} log log * @returns {object} data access object */ -export const createDataAccess = (config, log = console) => { - const dynamoClient = createClient(log); +export const createDataAccess = (config, log = console, client = undefined) => { + const dynamoClient = createClient(log, client); const auditFuncs = auditFunctions(dynamoClient, config, log); const keyEventFuncs = keyEventFunctions(dynamoClient, config, log); @@ -95,10 +90,8 @@ export const createDataAccess = (config, log = console) => { // electro-based data access objects const rawClient = createRawClient(); const electroService = createElectroService(rawClient, config, log); - const modelFactory = new ModelFactory(electroService, log); - - const Opportunity = modelFactory.getCollection(OpportunityCollection.name); - const Suggestion = modelFactory.getCollection(SuggestionCollection.name); + const entityRegistry = new EntityRegistry(electroService, log); + const collections = entityRegistry.getCollections(); return { ...auditFuncs, @@ -113,7 +106,6 @@ export const createDataAccess = (config, log = console) => { ...experimentFuncs, ...apiKeyFuncs, // electro-based data access objects - Opportunity, - Suggestion, + ...collections, }; }; diff --git a/packages/spacecat-shared-data-access/src/service/key-events/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/key-events/accessPatterns.js index 31a2833c..c8680a4a 100644 --- a/packages/spacecat-shared-data-access/src/service/key-events/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/key-events/accessPatterns.js @@ -18,7 +18,7 @@ import { KeyEventDto } from '../../dto/key-event.js'; * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {object} log - the logger object + * @param {object} log - the log object * @param {object} keyEventData - The key event data. * @returns {Promise>} newly created key event */ @@ -43,7 +43,7 @@ export const addKeyEvent = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site for which key events are being retrieved. * @param {boolean} ascending - Determines if the key events should be sorted ascending * or descending by createdAt. @@ -73,7 +73,7 @@ export const getKeyEventsForSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} keyEventId - The ID of the key event to remove. * @returns {Promise} */ diff --git a/packages/spacecat-shared-data-access/src/service/organizations/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/organizations/accessPatterns.js index 3def58d2..44210c65 100644 --- a/packages/spacecat-shared-data-access/src/service/organizations/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/organizations/accessPatterns.js @@ -81,7 +81,7 @@ export const getOrganizationByImsOrgID = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {object} organizationData - The organization data. * @returns {Promise>} */ @@ -106,7 +106,7 @@ export const addOrganization = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {Organization} organization - The organization. * @returns {Promise>} - The updated organization. */ @@ -139,7 +139,7 @@ export const updateOrganization = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} organizationId - The ID of the organization to remove. * @returns {Promise} */ diff --git a/packages/spacecat-shared-data-access/src/service/site-candidates/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/site-candidates/accessPatterns.js index 0cc0c37e..776c3547 100644 --- a/packages/spacecat-shared-data-access/src/service/site-candidates/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/site-candidates/accessPatterns.js @@ -58,7 +58,7 @@ export const getSiteCandidateByBaseURL = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {object} log - the logger object + * @param {object} log - the log object * @param {object} siteCandidateData - The site candidate data. * @returns {Promise>} newly created site candidate if hadn't created before */ diff --git a/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js index f4e9e1a0..148d5ec9 100644 --- a/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js +++ b/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js @@ -89,7 +89,7 @@ export const getSitesToAudit = async (dynamoClient, config) => { * the list. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} auditType - The type of audits to retrieve for the sites. * @param {boolean} [sortAuditsAscending=true] - Determines if the audits should be sorted in * ascending order. @@ -138,7 +138,7 @@ export const getSitesWithLatestAudit = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} baseURL - The base URL of the site to retrieve. * @returns {Promise|null>} A promise that resolves to the site object if found, * otherwise null. @@ -168,7 +168,7 @@ export const getSiteByBaseURL = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} baseUrl - The base URL of the site to retrieve. * @param {string} auditType - The type of audits to retrieve for the site. * @param {boolean} [latestOnly=false] - Determines if only the latest audit should be retrieved. @@ -215,7 +215,7 @@ export const getSiteByBaseURLWithAuditInfo = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} baseUrl - The base URL of the site to retrieve. * @param {string} auditType - The type of audits to retrieve for the site. * @returns {Promise|null>} A promise that resolves to the site object @@ -234,7 +234,7 @@ export const getSiteByBaseURLWithAudits = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} baseUrl - The base URL of the site to retrieve. * @param {string} auditType - The type of the latest audit to retrieve for the site. * @returns {Promise|null>} A promise that resolves to the site object @@ -272,7 +272,7 @@ export const getSitesByOrganizationID = async ( * The sortAuditsAscending parameter can be used to change the sort order. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} auditType - The type of audits to retrieve for the sites. * @param {string} organizationId - The organizationId to retrieve the sites. * @param {boolean} [sortAuditsAscending=true] - Determines if the audits should be sorted in @@ -321,7 +321,7 @@ export const getSitesByOrganizationIDWithLatestAudits = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site to retrieve. * @returns {Promise|null>} A promise that resolves to the site object if found, * otherwise null. @@ -341,7 +341,7 @@ export const getSiteByID = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {object} siteData - The site data. * @returns {Promise>} */ @@ -373,7 +373,7 @@ export const addSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {Site} site - The site. * @returns {Promise>} - The updated site. */ @@ -399,7 +399,7 @@ export const updateSite = async ( * * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} siteId - The ID of the site to remove. * @returns {Promise} */ @@ -446,7 +446,7 @@ async function removeSites( * Removes all sites for an organization. * @param {DynamoDbClient} dynamoClient - The DynamoDB client. * @param {DataAccessConfig} config - The data access config. - * @param {Logger} log - The logger. + * @param {Logger} log - The log. * @param {string} organizationId - The ID of the organization to remove the sites for. * @return {Promise} A promise that resolves when all sites for the organization have been * removed. diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.collection.js b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.collection.js new file mode 100755 index 00000000..292518e4 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.collection.js @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * ApiKeyCollection - A collection class responsible for managing ApiKey entities. + * Extends the BaseCollection to provide specific methods for interacting with ApiKey records. + * + * @class ApiKeyCollection + * @extends BaseCollection + */ +class ApiKeyCollection extends BaseCollection { + // add custom methods here +} + +export default ApiKeyCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.model.js b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.model.js new file mode 100644 index 00000000..c3030fcd --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.model.js @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import BaseModel from '../base/base.model.js'; + +/** + * ApiKey - A class representing an ApiKey entity. + * Provides methods to access and manipulate ApiKey-specific data. + * + * @class ApiKey + * @extends BaseModel + */ +class ApiKey extends BaseModel { + static SCOPE_NAMES = [ + 'sites.read_all', + 'sites.write_all', + 'organizations.read_all', + 'organizations.write_all', + 'audits.read_all', + 'audits.write_all', + 'imports.read', + 'imports.write', + 'imports.delete', + 'imports.read_all', + 'imports.all_domains', + 'imports.assistant', + ]; + + isValid() { + const now = new Date(); + + if (isIsoDate(this.getDeletedAt()) && new Date(this.getDeletedAt()) < now) { + return false; + } + + if (isIsoDate(this.getRevokedAt()) && new Date(this.getRevokedAt()) < now) { + return false; + } + + if (isIsoDate(this.getExpiresAt()) && new Date(this.getExpiresAt()) < now) { + return false; + } + + return true; + } +} + +export default ApiKey; diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.schema.js b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.schema.js new file mode 100644 index 00000000..358557b1 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/api-key.schema.js @@ -0,0 +1,82 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isIsoDate, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import ApiKey from './api-key.model.js'; +import ApiKeyCollection from './api-key.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(ApiKey, ApiKeyCollection) + .addAttribute('hashedApiKey', { + type: 'string', + required: true, + }) + .addAttribute('imsUserId', { + type: 'string', + }) + .addAttribute('imsOrgId', { + type: 'string', + }) + .addAttribute('name', { + type: 'string', + required: true, + }) + .addAttribute('deletedAt', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('expiresAt', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('revokedAt', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('scopes', { + type: 'list', + required: true, + items: { + type: 'map', + properties: { + domains: { + type: 'list', + items: { + type: 'string', + validate: (value) => isValidUrl(value), + }, + }, + name: { type: ApiKey.SCOPE_NAMES }, + }, + }, + }) + .addIndex( + 'byHashedApiKey', + { composite: ['hashedApiKey'] }, + { composite: ['updatedAt'] }, + ) + .addIndex( + 'byImsOrgIdAndImsUserId', + { composite: ['imsOrgId', 'imsUserId'] }, + { composite: ['updatedAt'] }, + ); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/api-key/index.d.ts new file mode 100644 index 00000000..f3352768 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/index.d.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel } from '../base'; + +export interface ApiKey extends BaseModel { + getDeletedAt(): string | undefined; + getExpiresAt(): string | undefined; + getHashedApiKey(): string; + getImsOrgId(): string | undefined; + getImsUserId(): string | undefined; + getName(): string; + getRevokedAt(): string | undefined; + getScopes(): string[]; + setDeletedAt(deletedAt: string): void; + setExpiresAt(expiresAt: string): void; + setHashedApiKey(hashedApiKey: string): void; + setImsOrgId(imsOrgId: string): void; + setImsUserId(imsUserId: string): void; + setName(name: string): void; + setRevokedAt(revokedAt: string): void; + setScopes(scopes: object[]): void; +} + +export interface ApiKeyCollection extends BaseCollection { + allByImsOrgIdAndImsUserId: (imsUserId: string, imsOrgId: string) => Promise; + findByHashedApiKey: (hashedApiKey: string) => Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/api-key/index.js b/packages/spacecat-shared-data-access/src/v2/models/api-key/index.js new file mode 100644 index 00000000..52de1cbb --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/api-key/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import ApiKey from './api-key.model.js'; +import ApiKeyCollection from './api-key.collection.js'; + +export { + ApiKey, + ApiKeyCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/audit.collection.js b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.collection.js new file mode 100755 index 00000000..b08fc02b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.collection.js @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * AuditCollection - A collection class responsible for managing Audit entities. + * Extends the BaseCollection to provide specific methods for interacting with Audit records. + * + * @class AuditCollection + * @extends BaseCollection + */ +class AuditCollection extends BaseCollection { + // add custom methods here +} + +export default AuditCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/audit.model.js b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.model.js new file mode 100644 index 00000000..34198334 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.model.js @@ -0,0 +1,89 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { isObject } from '@adobe/spacecat-shared-utils'; + +import { ValidationError } from '../../errors/index.js'; +import BaseModel from '../base/base.model.js'; + +const AUDIT_TYPES = { + 404: '404', + BROKEN_BACKLINKS: 'broken-backlinks', + EXPERIMENTATION: 'experimentation', + ORGANIC_KEYWORDS: 'organic-keywords', + ORGANIC_TRAFFIC: 'organic-traffic', + CWV: 'cwv', + LHS_DESKTOP: 'lhs-desktop', + LHS_MOBILE: 'lhs-mobile', + EXPERIMENTATION_ESS_MONTHLY: 'experimentation-ess-monthly', + EXPERIMENTATION_ESS_DAILY: 'experimentation-ess-daily', +}; + +const AUDIT_TYPE_PROPERTIES = { + [AUDIT_TYPES.LHS_DESKTOP]: ['performance', 'seo', 'accessibility', 'best-practices'], + [AUDIT_TYPES.LHS_MOBILE]: ['performance', 'seo', 'accessibility', 'best-practices'], +}; + +export const AUDIT_CONFIG = { + TYPES: AUDIT_TYPES, + PROPERTIES: AUDIT_TYPE_PROPERTIES, +}; + +/** + * Validates if the auditResult contains the required properties for the given audit type. + * @param {object} auditResult - The audit result to validate. + * @param {string} auditType - The type of the audit. + * @returns {boolean} - True if valid, false otherwise. + */ +export const validateAuditResult = (auditResult, auditType) => { + if (!isObject(auditResult) && !Array.isArray(auditResult)) { + throw new ValidationError('Audit result must be an object or array'); + } + + if (isObject(auditResult.runtimeError)) { + return true; + } + + if ((auditType === AUDIT_CONFIG.TYPES.LHS_MOBILE || auditType === AUDIT_CONFIG.TYPES.LHS_DESKTOP) + && !isObject(auditResult.scores)) { + throw new ValidationError(`Missing scores property for audit type '${auditType}'`); + } + + const expectedProperties = AUDIT_CONFIG.PROPERTIES[auditType]; + + if (expectedProperties) { + for (const prop of expectedProperties) { + if (!(prop in auditResult.scores)) { + throw new ValidationError(`Missing expected property '${prop}' for audit type '${auditType}'`); + } + } + } + + return true; +}; + +/** + * Audit - A class representing an Audit entity. + * Provides methods to access and manipulate Audit-specific data. + * + * @class Audit + * @extends BaseModel + */ +class Audit extends BaseModel { + // add your custom methods or overrides here + + getScores() { + return this.getAuditResult()?.scores; + } +} + +export default Audit; diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/audit.schema.js b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.schema.js new file mode 100644 index 00000000..1ab28252 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/audit.schema.js @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isIsoDate, isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Audit, { validateAuditResult } from './audit.model.js'; +import AuditCollection from './audit.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(Audit, AuditCollection) + .addReference('belongs_to', 'Site', ['auditType', 'auditedAt']) + .addReference('has_many', 'Opportunities') + .addAttribute('auditResult', { + type: 'any', + required: true, + validate: (value) => isNonEmptyObject(value), + set: (value, attributes) => { + // as the electroDb validate function does not provide access to the model instance + // we need to call the validate function from the model on setting the value + validateAuditResult(value, attributes.auditType); + return value; + }, + }) + .addAttribute('auditType', { + type: 'string', + required: true, + }) + .addAttribute('fullAuditRef', { + type: 'string', + required: true, + }) + .addAttribute('isLive', { + type: 'boolean', + required: true, + default: false, + }) + .addAttribute('isError', { + type: 'boolean', + required: true, + default: false, + }) + .addAttribute('auditedAt', { + type: 'string', + required: true, + default: () => new Date().toISOString(), + validate: (value) => isIsoDate(value), + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/audit/index.d.ts new file mode 100644 index 00000000..0e8995fd --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/index.d.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { + BaseCollection, BaseModel, Opportunity, Site, +} from '../index'; + +export interface Audit extends BaseModel { + getAuditResult(): object; + getAuditType(): string; + getAuditedAt(): number; + getFullAuditRef(): string; + getIsError(): boolean; + getIsLive(): boolean; + getOpportunities(): Promise; + getSite(): Promise; + getSiteId(): string; + setAuditResult(auditResult: object): Audit; + setAuditType(auditType: string): Audit; + setAuditedAt(auditedAt: number): Audit; + setFullAuditRef(fullAuditRef: string): Audit; + setIsError(isError: boolean): Audit; + setIsLive(isLive: boolean): Audit; + setSiteId(siteId: string): Audit; + toggleLive(): Audit; +} + +export interface AuditCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteAndType(siteId: string, auditType: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/audit/index.js b/packages/spacecat-shared-data-access/src/v2/models/audit/index.js new file mode 100644 index 00000000..d7125d08 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/audit/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import Audit from './audit.model.js'; +import AuditCollection from './audit.collection.js'; + +export { + Audit, + AuditCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base.collection.js b/packages/spacecat-shared-data-access/src/v2/models/base.collection.js deleted file mode 100755 index 1d8d8f63..00000000 --- a/packages/spacecat-shared-data-access/src/v2/models/base.collection.js +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; - -import { ElectroValidationError } from 'electrodb'; - -import ValidationError from '../errors/validation.error.js'; -import { guardId } from '../util/guards.js'; -import { keyNamesToIndexName } from '../util/reference.js'; - -/** - * BaseCollection - A base class for managing collections of entities in the application. - * This class uses ElectroDB to interact with entities and provides common functionality - * for data operations. - * - * @class BaseCollection - */ -class BaseCollection { - /** - * Constructs an instance of BaseCollection. - * @constructor - * @param {Object} electroService - The ElectroDB service used for managing entities. - * @param {Object} modelFactory - A factory for creating model instances. - * @param {BaseModel} clazz - The model class that represents the entity. - * @param {Object} log - A logger for capturing logging information. - */ - constructor(electroService, modelFactory, clazz, log) { - this.electroService = electroService; - this.modelFactory = modelFactory; - this.clazz = clazz; - this.entityName = this.clazz.name.toLowerCase(); - this.entity = electroService.entities[this.entityName]; - this.idName = `${this.entityName}Id`; - this.log = log; - } - - /** - * Creates an instance of a model from a record. - * @private - * @param {Object} record - The record containing data to create the model instance. - * @returns {BaseModel|null} - Returns an instance of the model class if the data is valid, - * otherwise null. - */ - #createInstance(record) { - if (!isNonEmptyObject(record?.data)) { - this.log.warn(`Failed to create instance of [${this.entityName}]: record is empty`); - return null; - } - // eslint-disable-next-line new-cap - return new this.clazz( - this.electroService, - this.modelFactory, - record.data, - this.log, - ); - } - - /** - * Creates instances of models from a set of records. - * @private - * @param {Object} records - The records containing data to create the model instances. - * @returns {Array} - An array of instances of the model class. - */ - #createInstances(records) { - if (!Array.isArray(records?.data)) { - this.log.warn(`Failed to create instances of [${this.entityName}]: records are empty`); - return []; - } - return records.data.map((record) => this.#createInstance({ data: record })); - } - - /** - * Retrieves the enum values for a field in the entity schema. Useful for validating - * enum values prior to creating or updating an entity. - * @param {string} fieldName - The name of the field to retrieve enum values for. - * @return {string[]} - An array of enum values for the field. - * @protected - */ - _getEnumValues(fieldName) { - return this.entity.model.schema.attributes[fieldName]?.enumArray; - } - - /** - * Finds an entity by its ID. - * @async - * @param {string} id - The unique identifier of the entity to be found. - * @returns {Promise} - A promise that resolves to an instance of - * the model if found, otherwise null. - * @throws {Error} - Throws an error if the ID is not provided. - */ - async findById(id) { - guardId(this.idName, id, this.entityName); - - const record = await this.entity.get({ [this.idName]: id }).go(); - - return this.#createInstance(record); - } - - /** - * Finds entities by a set of index keys. Index keys are used to query entities by - * a specific index defined in the entity schema. The index keys must match the - * fields defined in the index. - * @param {Object} keys - The index keys to use for the query. - * @return {Promise>} - A promise that resolves to an array of model instances. - * @throws {Error} - Throws an error if the index keys are not provided or if the index - * is not found. - * @async - */ - async findByIndexKeys(keys) { - if (!isNonEmptyObject(keys)) { - const message = `Failed to find by index keys [${this.entityName}]: keys are required`; - this.log.error(message); - throw new Error(message); - } - - const indexName = keyNamesToIndexName(Object.keys(keys)); - const index = this.entity.query[indexName]; - - if (!index) { - const message = `Failed to find by index keys [${this.entityName}]: index [${indexName}] not found`; - this.log.error(message); - throw new Error(message); - } - - const records = await index(keys).go(); - - return this.#createInstances(records); - } - - /** - * Creates a new entity in the collection and directly persists it to the database. - * There is no need to call the save method (which is for updates only) after creating - * the entity. - * @async - * @param {Object} item - The data for the entity to be created. - * @returns {Promise} - A promise that resolves to the created model instance. - * @throws {Error} - Throws an error if the data is invalid or if the creation process fails. - */ - async create(item) { - if (!isNonEmptyObject(item)) { - const message = `Failed to create [${this.entityName}]: data is required`; - this.log.error(message); - throw new Error(message); - } - - try { - // todo: catch ElectroDB validation errors and re-throws as ValidationError - // todo: validate associations - const record = await this.entity.create(item).go(); - return this.#createInstance(record); - } catch (error) { - this.log.error(`Failed to create [${this.entityName}]`, error); - throw error; - } - } - - /** - * Creates multiple entities in the collection and directly persists them to the database in - * a batch write operation. Batches are written in parallel and are limited to 25 items per batch. - * - * @async - * @param {Array} newItems - An array of data for the entities to be created. - * @param {BaseModel} [parent] - Optional parent entity that these items are associated with. - * @return {Promise<{ createdItems: BaseModel[], - * errorItems: { item: Object, error: ElectroValidationError }[] }>} - A promise that resolves to - * an object containing the created items and any items that failed validation. - * @throws {ValidationError} - Throws a validation error if any of the items has validation - * failures. - */ - async createMany(newItems, parent = null) { - if (!Array.isArray(newItems) || newItems.length === 0) { - const message = `Failed to create many [${this.entityName}]: items must be a non-empty array`; - this.log.error(message); - throw new Error(message); - } - - try { - const validatedItems = []; - const errorItems = []; - const createdItems = []; - - newItems.forEach((item) => { - try { - this.entity.put(item).params(); - validatedItems.push(item); - } catch (error) { - if (error instanceof ElectroValidationError) { - errorItems.push({ item, error: new ValidationError(error) }); - } - } - }); - - /** - * ElectroDB does not return the created items in the response for batch write operations. - * This listener intercepts the batch write requests and extracts the items before they - * are stored in the database. - * @param {Object} result - The result of the operation. - */ - const requestItemsListener = (result) => { - if (result?.type !== 'query' || result?.method !== 'batchWrite') { - return; - } - - result.params?.RequestItems[this.entity.model.table].forEach((putRequest) => { - createdItems.push(putRequest.PutRequest.Item); - }); - }; - - let records = []; - if (validatedItems.length > 0) { - const response = await this.entity.put(validatedItems).go( - { listeners: [requestItemsListener] }, - ); - records = this.#createInstances({ data: createdItems }); - - if (Array.isArray(response.unprocessed) && response.unprocessed.length > 0) { - this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); - } - } - - if (parent) { - records.forEach((record) => { - // eslint-disable-next-line no-underscore-dangle - record._cacheReference(parent.entity.model.name, parent); - }); - } - - return { createdItems: records, errorItems }; - } catch (error) { - this.log.error(`Failed to create many [${this.entityName}]`, error); - throw error; - } - } - - /** - * Updates a collection of entities in the database using a batch write (put) operation. - * - * @async - * @param {Array} items - An array of model instances to be updated. - * @return {Promise} - A promise that resolves when the update operation is complete. - * @throws {Error} - Throws an error if the update operation fails. - * @protected - */ - async _saveMany(items) { - if (!Array.isArray(items) || items.length === 0) { - const message = `Failed to save many [${this.entityName}]: items must be a non-empty array`; - this.log.error(message); - throw new Error(message); - } - - try { - const updates = items.map((item) => item.record); - const response = await this.entity.put(updates).go(); - - if (response.unprocessed) { - this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); - } - } catch (error) { - this.log.error(`Failed to save many [${this.entityName}]`, error); - throw error; - } - } -} - -export default BaseCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js b/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js new file mode 100755 index 00000000..f906000f --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js @@ -0,0 +1,450 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + hasText, + isNonEmptyObject, + isObject, +} from '@adobe/spacecat-shared-utils'; + +import { ElectroValidationError } from 'electrodb'; + +import { removeElectroProperties } from '../../../../test/it/util/util.js'; +import { createAccessors } from '../../util/accessor.utils.js'; +import ValidationError from '../../errors/validation.error.js'; +import { guardId } from '../../util/guards.js'; +import { + entityNameToAllPKValue, + isNonEmptyArray, + keyNamesToIndexName, +} from '../../util/util.js'; +import { INDEX_TYPES } from './constants.js'; + +function isValidParent(parent, child) { + if (!hasText(parent.entityName)) { + return false; + } + + const foreignKey = `${parent.entityName}Id`; + + return child.record?.[foreignKey] === parent.record?.[foreignKey]; +} + +/** + * Attempts to find an index name matching a generated name from the given keyNames. + * If no exact match is found, it progressively shortens the keyNames by removing the last one + * and tries again. If still no match, it tries the "all" index, and then "primary". + * + * @param {object} indexes - The available indexes, keyed by their names. + * @param {object} keys - The keys to find an index name for. + * @returns {object} The found index. + */ +function findIndexNameByKeys(indexes, keys) { + const keyNames = Object.keys(keys); + for (let { length } = keyNames; length > 0; length -= 1) { + const subKeyNames = keyNames.slice(0, length); + const candidateName = keyNamesToIndexName(subKeyNames); + if (indexes[candidateName]) { + return candidateName; + } + } + + if (indexes.all) { + return INDEX_TYPES.ALL; + } + + return INDEX_TYPES.PRIMARY; +} + +/** + * BaseCollection - A base class for managing collections of entities in the application. + * This class uses ElectroDB to interact with entities and provides common functionality + * for data operations. + * + * @class BaseCollection + * @abstract + */ +class BaseCollection { + /** + * Constructs an instance of BaseCollection. + * @constructor + * @param {Object} electroService - The ElectroDB service used for managing entities. + * @param {Object} entityRegistry - The registry holding entities, their schema and collection. + * @param {Object} schema - The schema for the entity. + * @param {Object} log - A log for capturing logging information. + */ + constructor(electroService, entityRegistry, schema, log) { + this.electroService = electroService; + this.entityRegistry = entityRegistry; + this.schema = schema; + this.log = log; + + this.clazz = this.schema.getModelClass(); + this.entityName = this.schema.getEntityName(); + this.idName = this.schema.getIdName(); + this.entity = electroService.entities[this.entityName]; + + this.#initializeCollectionMethods(); + } + + /** + * Initialize collection methods for each "by..." index defined in the entity schema. + * For each index that starts with "by", we: + * 1. Retrieve its composite pk and sk arrays from the schema. + * 2. Generate convenience methods for every prefix of the composite keys. + * For example, if the index keys are ['opportunityId', 'status', 'createdAt'], + * we create methods: + * - allByOpportunityId(...) / findByOpportunityId(...) + * - allByOpportunityIdAndStatus(...) / findByOpportunityIdAndStatus(...) + * - allByOpportunityIdAndStatusAndCreatedAt(...) / + * findByOpportunityIdAndStatusAndCreatedAt(...) + * + * Each generated method calls allByIndexKeys() or findByIndexKeys() with the appropriate keys. + * + * @private + */ + #initializeCollectionMethods() { + const accessorConfigs = this.schema.toAccessorConfigs(this, this.log); + createAccessors(accessorConfigs, this.log); + } + + /** + * Creates an instance of a model from a record. + * @private + * @param {Object} record - The record containing data to create the model instance. + * @returns {BaseModel|null} - Returns an instance of the model class if the data is valid, + * otherwise null. + */ + #createInstance(record) { + if (!isNonEmptyObject(record)) { + this.log.warn(`Failed to create instance of [${this.entityName}]: record is empty`); + return null; + } + // eslint-disable-next-line new-cap + return new this.clazz( + this.electroService, + this.entityRegistry, + this.schema, + record, + this.log, + ); + } + + /** + * Creates instances of models from a set of records. + * @private + * @param {Object} records - The records containing data to create the model instances. + * @returns {Array} - An array of instances of the model class. + */ + #createInstances(records) { + return records.map((record) => this.#createInstance(record)); + } + + #invalidateCache() { + this._accessorCache = {}; + } + + /** + * General method to query entities by index keys. This method is used by other + * query methods to perform the actual query operation. It will use the index keys + * to find the appropriate index and query the entities. The query result will be + * transformed into model instances. + * @private + * @param {Object} keys - The index keys to use for the query. + * @param {Object} options - Additional options for the query. + * @returns {Promise|null>} - The query result. + */ + async #queryByIndexKeys(keys, options = {}) { + if (!isNonEmptyObject(keys)) { + const message = `Failed to query [${this.entityName}]: keys are required`; + this.log.error(message); + throw new Error(message); + } + + if (!isObject(options)) { + const message = `Failed to query [${this.entityName}]: options must be an object`; + this.log.error(message); + throw new Error(message); + } + + const indexName = options.index || findIndexNameByKeys(this.entity.query, keys); + const index = this.entity.query[indexName]; + + if (!index) { + const message = `Failed to query [${this.entityName}]: index [${indexName}] not found`; + this.log.error(message); + throw new Error(message); + } + + const queryOptions = { + order: options.order || 'desc', + ...options.limit && { limit: options.limit }, + ...options.attributes && { attributes: options.attributes }, + }; + + let query = index(keys); + + if (isObject(options.between)) { + query = query.between( + { [options.between.attribute]: options.between.start }, + { [options.between.attribute]: options.between.end }, + ); + } + + const records = await query.go(queryOptions); + + if (options.limit === 1) { + if (records.data?.length === 0) { + return null; + } + return this.#createInstance(records.data[0]); + } else { + return this.#createInstances(records.data); + } + } + + /** + * Finds all entities in the collection. Requires an index named "all" with a partition key + * named "pk" with a static value of "ALL_". + * @param {Object} [sortKeys] - The sort keys to use for the query. + * @param {Object} [options] - Additional options for the query. + * @return {Promise|null>} + */ + async all(sortKeys = {}, options = {}) { + const keys = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys }; + return this.#queryByIndexKeys(keys, options); + } + + /** + * Finds entities by a set of index keys. Index keys are used to query entities by + * a specific index defined in the entity schema. The index keys must match the + * fields defined in the index. + * @param {Object} keys - The index keys to use for the query. + * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query. + * @return {Promise>} - A promise that resolves to an array of model instances. + * @throws {Error} - Throws an error if the index keys are not provided or if the index + * is not found. + * @async + */ + async allByIndexKeys(keys, options = {}) { + return this.#queryByIndexKeys(keys, options); + } + + /** + * Finds a single entity from the "all" index. Requires an index named "all" with a partition key + * named "pk" with a static value of "ALL_". + * @param {Object} [sortKeys] - The sort keys to use for the query. + * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query. + * @return {Promise|null>} + */ + async findByAll(sortKeys = {}, options = {}) { + if (!isObject(sortKeys)) { + const message = `Failed to find by all [${this.entityName}]: sort keys must be an object`; + this.log.error(message); + throw new Error(message); + } + + const keys = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys }; + return this.#queryByIndexKeys(keys, { ...options, index: INDEX_TYPES.ALL, limit: 1 }); + } + + /** + * Finds an entity by its ID. + * @async + * @param {string} id - The unique identifier of the entity to be found. + * @returns {Promise} - A promise that resolves to an instance of + * the model if found, otherwise null. + * @throws {Error} - Throws an error if the ID is not provided. + */ + async findById(id) { + guardId(this.idName, id, this.entityName); + + const record = await this.entity.get({ [this.idName]: id }).go(); + + return this.#createInstance(record?.data); + } + + /** + * Finds a single entity by index keys. + * @param {Object} keys - The index keys to use for the query. + * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query. + * @returns {Promise} - A promise that resolves to the model instance or null. + * @async + */ + async findByIndexKeys(keys, options = {}) { + return this.#queryByIndexKeys(keys, { ...options, limit: 1 }); + } + + /** + * Creates a new entity in the collection and directly persists it to the database. + * There is no need to call the save method (which is for updates only) after creating + * the entity. + * @async + * @param {Object} item - The data for the entity to be created. + * @returns {Promise} - A promise that resolves to the created model instance. + * @throws {Error} - Throws an error if the data is invalid or if the creation process fails. + */ + async create(item) { + if (!isNonEmptyObject(item)) { + const message = `Failed to create [${this.entityName}]: data is required`; + this.log.error(message); + throw new Error(message); + } + + try { + const record = await this.entity.create(item).go(); + const instance = this.#createInstance(record.data); + + this.#invalidateCache(); + + return instance; + } catch (error) { + this.log.error(`Failed to create [${this.entityName}]`, error); + throw error; + } + } + + /** + * Validates and batches items for batch operations. + * @private + * @param {Array} items - Items to be validated. + * @returns {Object} - An object containing validated items and error items. + */ + #validateItems(items) { + const validatedItems = []; + const errorItems = []; + + items.forEach((item) => { + try { + const { Item } = this.entity.put(item).params(); + validatedItems.push({ ...removeElectroProperties(Item), ...item }); + } catch (error) { + if (error instanceof ElectroValidationError) { + errorItems.push({ item, error: new ValidationError(error) }); + } + } + }); + + return { validatedItems, errorItems }; + } + + /** + * Creates multiple entities in the collection and directly persists them to the database in + * a batch write operation. Batches are written in parallel and are limited to 25 items per batch. + * + * @async + * @param {Array} newItems - An array of data for the entities to be created. + * @param {BaseModel} [parent] - Optional parent entity that these items are associated with. + * @return {Promise<{ createdItems: BaseModel[], + * errorItems: { item: Object, error: ValidationError }[] }>} - A promise that resolves to + * an object containing the created items and any items that failed validation. + * @throws {ValidationError} - Throws a validation error if any of the items has validation + * failures. + */ + async createMany(newItems, parent = null) { + if (!isNonEmptyArray(newItems)) { + const message = `Failed to create many [${this.entityName}]: items must be a non-empty array`; + this.log.error(message); + throw new Error(message); + } + + try { + const { validatedItems, errorItems } = this.#validateItems(newItems); + + if (validatedItems.length > 0) { + const response = await this.entity.put(validatedItems).go(); + + if (isNonEmptyArray(response?.unprocessed)) { + this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); + } + } + + const createdItems = this.#createInstances(validatedItems); + + if (isNonEmptyObject(parent)) { + createdItems.forEach((record) => { + if (!isValidParent(parent, record)) { + this.log.warn(`Failed to associate parent with child [${this.entityName}]: parent is invalid`); + return; + } + // eslint-disable-next-line no-underscore-dangle,no-param-reassign + record._accessorCache[`get${parent.schema.getModelName()}`] = parent; + }); + } + + this.#invalidateCache(); + + this.log.info(`Created ${createdItems.length} items for [${this.entityName}]`); + + return { createdItems, errorItems }; + } catch (error) { + this.log.error(`Failed to create many [${this.entityName}]`, error); + throw error; + } + } + + /** + * Updates a collection of entities in the database using a batch write (put) operation. + * + * @async + * @param {Array} items - An array of model instances to be updated. + * @return {Promise} - A promise that resolves when the update operation is complete. + * @throws {Error} - Throws an error if the update operation fails. + * @protected + */ + async _saveMany(items) { + if (!isNonEmptyArray(items)) { + const message = `Failed to save many [${this.entityName}]: items must be a non-empty array`; + this.log.error(message); + throw new Error(message); + } + + try { + const updates = items.map((item) => item.record); + const response = await this.entity.put(updates).go(); + + this.#invalidateCache(); + + if (response.unprocessed) { + this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); + } + } catch (error) { + this.log.error(`Failed to save many [${this.entityName}]`, error); + throw error; + } + } + + /** + * Removes all records of this entity based on the provided IDs. This will perform a batch + * delete operation. This operation does not remove dependent records. + * @param {Array} ids - An array of IDs to remove. + * @return {Promise} - A promise that resolves when the removal operation is complete. + * @throws {Error} - Throws an error if the IDs are not provided or if the + * removal operation fails. + */ + async removeByIds(ids) { + if (!isNonEmptyArray(ids)) { + const message = `Failed to remove [${this.entityName}]: ids must be a non-empty array`; + this.log.error(message); + throw new Error(message); + } + + this.log.info(`Removing ${ids.length} items for [${this.entityName}]`); + // todo: consider removing dependent records + + await this.entity.delete(ids.map((id) => ({ [this.idName]: id }))).go(); + + this.#invalidateCache(); + } +} + +export default BaseCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base.model.js b/packages/spacecat-shared-data-access/src/v2/models/base/base.model.js similarity index 51% rename from packages/spacecat-shared-data-access/src/v2/models/base.model.js rename to packages/spacecat-shared-data-access/src/v2/models/base/base.model.js index cd45adad..f0e6695c 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/base.model.js +++ b/packages/spacecat-shared-data-access/src/v2/models/base/base.model.js @@ -12,13 +12,16 @@ import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; -import Patcher from '../util/patcher.js'; +import { createAccessors } from '../../util/accessor.utils.js'; +import Patcher from '../../util/patcher.js'; import { capitalize, - entityNameToCollectionName, entityNameToIdName, - entityNameToReferenceMethodName, idNameToEntityName, -} from '../util/reference.js'; + idNameToEntityName, + isNonEmptyArray, +} from '../../util/util.js'; + +import Reference from './reference.js'; /** * Base - A base class for representing individual entities in the application. @@ -40,20 +43,28 @@ class BaseModel { * Constructs an instance of BaseModel. * @constructor * @param {Object} electroService - The ElectroDB service used for managing entities. - * @param {Object} modelFactory - A factory for creating model instances. + * @param {EntityRegistry} entityRegistry - The registry holding entities, their schema + * and collection. + * @param {Schema} schema - The schema for the entity. * @param {Object} record - The initial data for the entity instance. - * @param {Object} log - A logger for capturing logging information. + * @param {Object} log - A log for capturing logging information. */ - constructor(electroService, modelFactory, record, log) { - this.modelFactory = modelFactory; + constructor(electroService, entityRegistry, schema, record, log) { + this.electroService = electroService; + this.entityRegistry = entityRegistry; + this.schema = schema; this.record = record; - this.entityName = this.constructor.name.toLowerCase(); - this.entity = electroService.entities[this.entityName]; - this.idName = `${this.entityName}Id`; this.log = log; - this.referencesCache = {}; - this.patcher = new Patcher(this.entity, this.record); + this.entityName = schema.getEntityName(); + this.idName = entityNameToIdName(this.entityName); + + this.collection = entityRegistry.getCollection(schema.getCollectionName()); + this.entity = electroService.entities[this.entityName]; + + this.patcher = new Patcher(this.entity, this.schema, this.record); + + this._accessorCache = {}; this.#initializeReferences(); this.#initializeAttributes(); @@ -66,23 +77,16 @@ class BaseModel { * @private */ #initializeReferences() { - const { references } = this.entity.model.original; - if (!isNonEmptyObject(references)) { - return; - } - - for (const [type, refs] of Object.entries(references)) { - refs.forEach((ref) => { - const { target } = ref; - const methodName = entityNameToReferenceMethodName(target, type); + const references = this.schema.getReferences(); - this[methodName] = async () => this._fetchReference(type, target); - }); - } + references.forEach((reference) => { + const accessorConfigs = reference.toAccessorConfigs(this.entityRegistry, this); + createAccessors(accessorConfigs, this.log); + }); } #initializeAttributes() { - const { attributes } = this.entity.model.schema; + const attributes = this.schema.getAttributes(); if (!isNonEmptyObject(attributes)) { return; @@ -92,8 +96,9 @@ class BaseModel { const capitalized = capitalize(name); const getterMethodName = `get${capitalized}`; const setterMethodName = `set${capitalized}`; - const isReference = this.entity.model.original - .references?.belongs_to?.some((ref) => ref.target === idNameToEntityName(name)); + const isReference = this.schema + .getReferencesByType(Reference.TYPES.BELONGS_TO) + .some((ref) => ref.getTarget() === idNameToEntityName(name)); if (!this[getterMethodName] || name === this.idName) { this[getterMethodName] = () => this.record[name]; @@ -108,64 +113,43 @@ class BaseModel { } } - /** - * Gets a cached reference for the specified entity. - * @param {string} targetName - The name of the entity to fetch. - * @return {*} - */ - #getCachedReference(targetName) { - return this.referencesCache[targetName]; - } - - /** - * Caches a reference for the specified entity. This method is used to store - * fetched references to avoid redundant database queries. - * @param {string} targetName - The name of the entity to cache. - * @param {*} reference - The reference to cache. - * @private - */ - _cacheReference(targetName, reference) { - this.referencesCache[targetName] = reference; + #invalidateCache() { + this._accessorCache = {}; } - /** - * Fetches a reference for the specified entity. This method is used to fetch - * associated entities based on the type of relationship (belongs_to, has_one, has_many). - * The fetched references are cached to avoid redundant database queries. If the reference - * is already cached, it will be returned directly. - * References are defined in the entity model and are used to fetch associated entities. - * @async - * @param {string} type - The type of relationship (belongs_to, has_one, has_many). - * @param {string} targetName - The name of the entity to fetch. - * @return {Promise<*|null>} - A promise that resolves to the fetched reference or null if - * not found. - * @private - */ - async _fetchReference(type, targetName) { /* eslint-disable no-underscore-dangle */ - let result = this.#getCachedReference(targetName); - if (result) { - return result; - } - - const collectionName = entityNameToCollectionName(targetName); - const targetCollection = this.modelFactory.getCollection(collectionName); - - if (type === 'belongs_to' || type === 'has_one') { - const foreignKey = entityNameToIdName(targetName); - const id = this.record[foreignKey]; - if (!id) return null; - - result = await targetCollection.findById(id); - } else if (type === 'has_many') { - const foreignKey = entityNameToIdName(this.entityName); - result = await targetCollection.findByIndexKeys({ [foreignKey]: this.getId() }); - } + async #fetchDependents() { + const promises = []; + + const relationshipTypes = [ + Reference.TYPES.HAS_MANY, + Reference.TYPES.HAS_ONE, + ]; + + relationshipTypes.forEach((type) => { + const references = this.schema.getReferencesByType(type); + const targets = references.filter((reference) => reference.isRemoveDependents()); + + targets.forEach((reference) => { + const accessors = reference.toAccessorConfigs(this.entityRegistry, this); + const methodName = accessors[0].name; + promises.push( + this[methodName]() + .then((dependent) => { + if (isNonEmptyArray(dependent)) { + return dependent; + } else if (isNonEmptyObject(dependent)) { + return [dependent]; + } + + return null; + }), + ); + }); + }); - if (result) { - await this._cacheReference(targetName, result); - } + const results = await Promise.all(promises); - return result; + return results.flat().filter((dependent) => dependent !== null); } /** @@ -181,7 +165,7 @@ class BaseModel { * @returns {string} - The ISO string representing when the entity was created. */ getCreatedAt() { - return new Date(this.record.createdAt).toISOString(); + return this.record.createdAt; } /** @@ -189,20 +173,37 @@ class BaseModel { * @returns {string} - The ISO string representing when the entity was last updated. */ getUpdatedAt() { - return new Date(this.record.updatedAt).toISOString(); + return this.record.updatedAt; } /** - * Removes the current entity from the database. + * Removes the current entity from the database. This method also removes any dependent + * entities associated with the current entity. For example, if the current entity has + * a has_many relationship with another entity, the dependent entity will be removed. + * When adding a reference to an entity, the dependent entity will be removed if the + * removeDependentss flag is set to true in the reference definition. + * + * Dependents are removed by calling the remove method on each dependent entity, which in turn + * will also remove any dependent entities associated with the dependent entity. This may result + * in a cascade effect where multiple entities are removed. Consider the destructive + * and performance implications before using this method. * @async * @returns {Promise} - A promise that resolves to the current instance of the entity - * after it has been removed. + * after it and its dependents have been removed. * @throws {Error} - Throws an error if the removal fails. */ async remove() { try { - // todo: remove dependents (child associations) - await this.entity.remove({ [this.idName]: this.getId() }).go(); + const dependents = await this.#fetchDependents(); + const removePromises = dependents.map((dependent) => dependent.remove()); + removePromises.push(this.entity.remove({ [this.idName]: this.getId() }).go()); + + this.log.info(`Removing entity ${this.entityName} with ID ${this.getId()} and ${dependents.length} dependents`); + + await Promise.all(removePromises); + + this.#invalidateCache(); + return this; } catch (error) { this.log.error('Failed to remove record', error); @@ -221,14 +222,33 @@ class BaseModel { async save() { // todo: validate associations try { + this.log.info(`Saving entity ${this.entityName} with ID ${this.getId()}`); + await this.patcher.save(); - // todo: in case references are updated, clear or refresh references cache + this.#invalidateCache(); + return this; } catch (error) { this.log.error('Failed to save record', error); throw error; } } + + /** + * Converts the entity attributes to a JSON object. + * @returns {Object} - A JSON representation of the entity attributes. + */ + toJSON() { + const attributes = this.schema.getAttributes(); + + return Object.keys(attributes).reduce((json, key) => { + if (this.record[key] !== undefined) { + // eslint-disable-next-line no-param-reassign + json[key] = this.record[key]; + } + return json; + }, {}); + } } export default BaseModel; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/constants.js b/packages/spacecat-shared-data-access/src/v2/models/base/constants.js new file mode 100644 index 00000000..76611c0c --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/constants.js @@ -0,0 +1,17 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +export const INDEX_TYPES = { + PRIMARY: 'primary', + ALL: 'all', + BELONGS_TO: 'belongs_to', + OTHER: 'other', +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/entity.registry.js b/packages/spacecat-shared-data-access/src/v2/models/base/entity.registry.js new file mode 100755 index 00000000..779b62ab --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/entity.registry.js @@ -0,0 +1,137 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { collectionNameToEntityName, decapitalize } from '../../util/util.js'; + +import ApiKeyCollection from '../api-key/api-key.collection.js'; +import AuditCollection from '../audit/audit.collection.js'; +import ConfigurationCollection from '../configuration/configuration.collection.js'; +import ExperimentCollection from '../experiment/experiment.collection.js'; +import ImportJobCollection from '../import-job/import-job.collection.js'; +import ImportUrlCollection from '../import-url/import-url.collection.js'; +import KeyEventCollection from '../key-event/key-event.collection.js'; +import OpportunityCollection from '../opportunity/opportunity.collection.js'; +import OrganizationCollection from '../organization/organization.collection.js'; +import SiteCandidateCollection from '../site-candidate/site-candidate.collection.js'; +import SiteCollection from '../site/site.collection.js'; +import SiteTopPageCollection from '../site-top-page/site-top-page.collection.js'; +import SuggestionCollection from '../suggestion/suggestion.collection.js'; + +import ApiKeySchema from '../api-key/api-key.schema.js'; +import AuditSchema from '../audit/audit.schema.js'; +import ConfigurationSchema from '../configuration/configuration.schema.js'; +import ExperimentSchema from '../experiment/experiment.schema.js'; +import ImportJobSchema from '../import-job/import-job.schema.js'; +import ImportUrlSchema from '../import-url/import-url.schema.js'; +import KeyEventSchema from '../key-event/key-event.schema.js'; +import OpportunitySchema from '../opportunity/opportunity.schema.js'; +import OrganizationSchema from '../organization/organization.schema.js'; +import SiteSchema from '../site/site.schema.js'; +import SiteCandidateSchema from '../site-candidate/site-candidate.schema.js'; +import SiteTopPageSchema from '../site-top-page/site-top-page.schema.js'; +import SuggestionSchema from '../suggestion/suggestion.schema.js'; + +/** + * EntityRegistry - A registry class responsible for managing entities, their schema and collection. + * + * @class EntityRegistry + */ +class EntityRegistry { + static entities = {}; + + /** + * Constructs an instance of EntityRegistry. + * @constructor + * @param {Object} service - The ElectroDB service instance used to manage entities. + * @param {Object} log - A logger for capturing and logging information. + */ + constructor(service, log) { + this.service = service; + this.log = log; + this.collections = new Map(); + + this.#initialize(); + } + + /** + * Initializes the collections managed by the EntityRegistry. + * This method creates instances of each collection and stores them in an internal map. + * @private + */ + #initialize() { + Object.values(EntityRegistry.entities).forEach(({ collection: Collection, schema }) => { + const collection = new Collection(this.service, this, schema, this.log); + this.collections.set(Collection.name, collection); + }); + + this.#logIndexes(); + } + + #logIndexes() { + // reduce collection schema indexes into object + const indexes = Object.values(EntityRegistry.entities).reduce((acc, { schema }) => { + acc[schema.getEntityName()] = schema.indexes; + return acc; + }, {}); + + this.log.debug('Indexes:', JSON.stringify(indexes, null, 2)); + } + + /** + * Gets a collection instance by its name. + * @param {string} collectionName - The name of the collection to retrieve. + * @returns {Object} - The requested collection instance. + * @throws {Error} - Throws an error if the collection with the specified name is not found. + */ + getCollection(collectionName) { + const collection = this.collections.get(collectionName); + if (!collection) { + throw new Error(`Collection ${collectionName} not found`); + } + return collection; + } + + getCollections() { + const collections = {}; + for (const [key, value] of this.collections) { + collections[collectionNameToEntityName(key)] = value; + } + return collections; + } + + static getEntities() { + return Object.keys(this.entities).reduce((acc, key) => { + acc[key] = this.entities[key].schema.toElectroDBSchema(); + return acc; + }, {}); + } + + static registerEntity(schema, collection) { + this.entities[decapitalize(schema.getEntityName())] = { schema, collection }; + } +} + +EntityRegistry.registerEntity(ApiKeySchema, ApiKeyCollection); +EntityRegistry.registerEntity(AuditSchema, AuditCollection); +EntityRegistry.registerEntity(ConfigurationSchema, ConfigurationCollection); +EntityRegistry.registerEntity(ExperimentSchema, ExperimentCollection); +EntityRegistry.registerEntity(ImportJobSchema, ImportJobCollection); +EntityRegistry.registerEntity(ImportUrlSchema, ImportUrlCollection); +EntityRegistry.registerEntity(KeyEventSchema, KeyEventCollection); +EntityRegistry.registerEntity(OpportunitySchema, OpportunityCollection); +EntityRegistry.registerEntity(OrganizationSchema, OrganizationCollection); +EntityRegistry.registerEntity(SiteSchema, SiteCollection); +EntityRegistry.registerEntity(SiteCandidateSchema, SiteCandidateCollection); +EntityRegistry.registerEntity(SiteTopPageSchema, SiteTopPageCollection); +EntityRegistry.registerEntity(SuggestionSchema, SuggestionCollection); + +export default EntityRegistry; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/base/index.d.ts new file mode 100644 index 00000000..b759ea09 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/index.d.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ValidationError } from '../../errors'; + +export interface BaseModel { + getCreatedAt(): string; + getId(): string; + getUpdatedAt(): string; + remove(): Promise; + save(): Promise; + toJSON(): object; +} + +export interface MultiStatusCreateResult { + createdItems: T[], + errorItems: { item: object, error: ValidationError }[], +} + +export interface QueryOptions { + index?: string; + limit?: number; + sort?: string; + attributes?: string[]; +} + +export interface BaseCollection { + all(sortKeys?: object, options?: QueryOptions): Promise; + allByIndexKeys(keys: object, options?: QueryOptions): Promise; + create(item: object): Promise; + createMany(items: object[]): Promise>; + findByAll(sortKeys?: object, options?: QueryOptions): Promise; + findById(id: string): Promise; + findByIndexKeys(indexKeys: object): Promise; + removeByIds(ids: string[]): Promise; +} + +export interface EntityRegistry { + getCollection(collectionName: string): BaseCollection; + getCollections(): BaseCollection[]; + getEntities(): object; + registerEntity(schema: object, collection: BaseCollection): void; +} + +export interface Reference { + getSortKeys(): string[]; + getTarget(): string; + getType(): string; + isRemoveDependents(): boolean; +} + +export interface Schema { + getAttribute(name: string): object; + getAttributes(): object; + getCollectionName(): string; + getEntityName(): string; + getIdName(): string; + getIndexes(): object; + getIndexKeys(indexName: string): string[]; + getModelClass(): object; + getModelName(): string; + getReferences(): Reference[]; + getReferencesByType(referenceType: string): Reference[]; + getReferenceByTypeAndTarget(referenceType: string, target: string): Reference | undefined; +} + +export interface SchemaBuilder { + addAttribute(name: string, data: object): SchemaBuilder; + addAllIndexWithComposite(...attributeNames: string[]): SchemaBuilder + addAllIndexWithTemplateField(fieldName: string, template: string): SchemaBuilder; + addIndex(name: string, partitionKey: object, sortKey: object): SchemaBuilder; + addReference(referenceType: string, entityName: string, sortKeys?: string[]): SchemaBuilder; + build(): Schema; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/index.js b/packages/spacecat-shared-data-access/src/v2/models/base/index.js new file mode 100644 index 00000000..765ca21f --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/index.js @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from './base.model.js'; +import BaseCollection from './base.collection.js'; +import EntityRegistry from './entity.registry.js'; +import Reference from './reference.js'; +import Schema from './schema.js'; +import SchemaBuilder from './schema.builder.js'; + +export { + BaseModel, + BaseCollection, + EntityRegistry, + Reference, + Schema, + SchemaBuilder, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/reference.js b/packages/spacecat-shared-data-access/src/v2/models/base/reference.js new file mode 100644 index 00000000..0e2519eb --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/reference.js @@ -0,0 +1,159 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { hasText } from '@adobe/spacecat-shared-utils'; +import { + entityNameToCollectionName, + entityNameToIdName, + isNonEmptyArray, + keyNamesToMethodName, + referenceToBaseMethodName, +} from '../../util/util.js'; + +class Reference { + static TYPES = { + BELONGS_TO: 'belongs_to', + HAS_MANY: 'has_many', + HAS_ONE: 'has_one', + }; + + static fromJSON(json) { + return new Reference(json.type, json.target, json.options); + } + + static isValidType(type) { + return Object.values(Reference.TYPES).includes(type); + } + + constructor(type, target, options = {}) { + if (!Reference.isValidType(type)) { + throw new Error(`Invalid reference type: ${type}`); + } + + if (!hasText(target)) { + throw new Error('Invalid target'); + } + + this.type = type; + this.target = target; + this.options = options; + } + + getSortKeys() { + return this.options.sortKeys; + } + + getTarget() { + return this.target; + } + + getType() { + return this.type; + } + + isRemoveDependents() { + return this.options.removeDependents; + } + + toAccessorConfigs(registry, entity) { + const { log } = registry; + const accessorConfigs = []; + + const target = this.getTarget(); + const type = this.getType(); + + const baseMethodName = referenceToBaseMethodName(this); + const collectionName = entityNameToCollectionName(target); + const targetCollection = registry.getCollection(collectionName); + + switch (type) { + case Reference.TYPES.BELONGS_TO: { + const foreignKeyName = entityNameToIdName(target); + const foreignKeyValue = entity.record[foreignKeyName]; + + // belongs_to: direct findById + accessorConfigs.push({ + name: baseMethodName, + requiredKeys: [], + foreignKey: { name: foreignKeyName, value: foreignKeyValue }, + byId: true, + }); + break; + } + + case Reference.TYPES.HAS_ONE: { + const foreignKeyName = entityNameToIdName(entity.entityName); + const foreignKeyValue = entity.getId(); + + // has_one yields a single record. + accessorConfigs.push({ + name: baseMethodName, + requiredKeys: [], + foreignKey: { name: foreignKeyName, value: foreignKeyValue }, + }); + break; + } + + case Reference.TYPES.HAS_MANY: { + const foreignKeyName = entityNameToIdName(entity.entityName); + const foreignKeyValue = entity.getId(); + + // has_many yields multiple records. + accessorConfigs.push({ + name: baseMethodName, + requiredKeys: [], + all: true, + foreignKey: { name: foreignKeyName, value: foreignKeyValue }, + }); + + const belongsToRef = targetCollection.schema.getReferenceByTypeAndTarget( + Reference.TYPES.BELONGS_TO, + entity.schema.getModelName(), + ); + + if (!belongsToRef) { + log.warn(`Reciprocal reference not found for ${entity.schema.getModelName()} to ${target}`); + break; + } + + const sortKeys = belongsToRef.getSortKeys(); + if (!isNonEmptyArray(sortKeys)) { + log.debug(`No sort keys defined for ${entity.schema.getModelName()} to ${target}`); + break; + } + + for (let i = 1; i <= sortKeys.length; i += 1) { + const subset = sortKeys.slice(0, i); + accessorConfigs.push({ + name: keyNamesToMethodName(subset, `${baseMethodName}By`), + requiredKeys: subset, + all: true, + foreignKey: { name: foreignKeyName, value: foreignKeyValue }, + }); + } + + break; + } + + default: + throw new Error(`Unsupported reference type: ${type}`); + } + + return accessorConfigs.map((config) => ({ + ...config, + collection: targetCollection, + context: entity, + })); + } +} + +export default Reference; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/schema.builder.js b/packages/spacecat-shared-data-access/src/v2/models/base/schema.builder.js new file mode 100755 index 00000000..ff7cbf44 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/schema.builder.js @@ -0,0 +1,420 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { hasText, isInteger, isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import { v4 as uuid, validate as uuidValidate } from 'uuid'; + +import { + capitalize, + decapitalize, + entityNameToAllPKValue, + entityNameToIdName, isNonEmptyArray, +} from '../../util/util.js'; + +import { INDEX_TYPES } from './constants.js'; +import BaseModel from './base.model.js'; +import BaseCollection from './base.collection.js'; +import Reference from './reference.js'; +import Schema from './schema.js'; + +const DEFAULT_SERVICE_NAME = 'SpaceCat'; + +/** + * ID attribute configuration object. + * Ensures a UUID-based "primary key". + * @type {object} + */ +const ID_ATTRIBUTE_DATA = { + type: 'string', + required: true, + readOnly: true, + // https://electrodb.dev/en/modeling/attributes/#default + default: () => uuid(), + // https://electrodb.dev/en/modeling/attributes/#attribute-validation + validate: (value) => uuidValidate(value), +}; + +/** + * CreatedAt attribute configuration object. + * Automatically sets to current date/time at creation. + * @type {object} + */ +const CREATED_AT_ATTRIBUTE_DATA = { + type: 'string', + readOnly: true, + required: true, + default: () => new Date().toISOString(), +}; + +/** + * UpdatedAt attribute configuration object. + * Automatically updates to current date/time whenever the entity is modified. + * @type {object} + */ +const UPDATED_AT_ATTRIBUTE_DATA = { + type: 'string', + required: true, + readOnly: true, + watch: '*', + default: () => new Date().toISOString(), + set: () => new Date().toISOString(), +}; + +/** Certain index names (primary, all) are reserved and cannot be reused. */ +const RESERVED_INDEX_NAMES = [INDEX_TYPES.PRIMARY, INDEX_TYPES.ALL]; + +/** + * Constructs a fully qualified index name. + * @param {string} service - The name of the service. + * @param {string} entity - The name of the entity. + * @param {string} name - The index name (e.g., 'all', 'byForeignKey'). + * @returns {string} The fully qualified index name. + */ +const createdIndexName = (service, entity, name) => `${service.toLowerCase()}-data-${entity}-${name}`; + +/** + * Sorts an indexes object by its keys alphabetically. + * @param {object} indexes - An object whose keys are index names and values are index definitions. + * @returns {object} A new object with the same entries, but keys sorted alphabetically. + */ +const sortIndexes = (indexes) => Object.fromEntries( + Object.entries(indexes).sort((a, b) => a[0].localeCompare(b[0])), +); + +/** + * Assigns GSI field names to indexes that don't have them yet. + * Ensures that if an "all" index exists, it uses gsi1 (already assigned) + * and other indexes continue numbering from gsi2 onwards. + * + * @param {object} indexes - Object of indexes that require naming. + * @param {object|null} all - The "all" index object if present, null otherwise. + */ +const numberGSIsIndexes = (indexes, all) => { + // if there's an "all" index, we start indexing subsequent GSIs from 2, + // because "all" index already occupies gsi1. + // if no "all" index exists, start from 1. + let gsiCounter = isNonEmptyObject(all) ? 1 : 0; + + Object.values(indexes).forEach((index) => { /* eslint-disable no-param-reassign */ + // only assign new field names and number through if none are provided. + if (!index.pk.field || !index.sk.field) { + gsiCounter += 1; + } + + index.pk.field = index.pk.field || `gsi${gsiCounter}pk`; + index.sk.field = index.sk.field || `gsi${gsiCounter}sk`; + }); +}; + +/** + * The SchemaBuilder class allows for constructing a schema definition + * including attributes, indexes, and references to other entities. + * Index ordering is enforced at build time for deterministic output: + * - primary index first + * - "all" index second (if present) + * - all "belongs_to" indexes sorted alphabetically next + * - all "other" indexes sorted alphabetically last + */ +class SchemaBuilder { + /** + * Creates a new SchemaBuilder instance. + * + * @param {BaseModel} modelClass - The model class for this entity. + * @param {BaseCollection} collectionClass - The collection class for this entity. + * @param {number} schemaVersion - A positive integer representing the schema's version. + * @throws {Error} If entityName is not a non-empty string. + * @throws {Error} If schemaVersion is not a positive integer. + * @throws {Error} If serviceName is not a non-empty string. + */ + constructor(modelClass, collectionClass, schemaVersion = 1) { + if (!modelClass || !(modelClass.prototype instanceof BaseModel)) { + throw new Error('modelClass must be a subclass of BaseModel.'); + } + + if (!collectionClass || !(collectionClass.prototype instanceof BaseCollection)) { + throw new Error('collectionClass must be a subclass of BaseCollection.'); + } + + if (!isInteger(schemaVersion) || schemaVersion < 1) { + throw new Error('schemaVersion is required and must be a positive integer.'); + } + + this.modelClass = modelClass; + this.collectionClass = collectionClass; + this.schemaVersion = schemaVersion; + this.entityName = modelClass.name; + this.serviceName = DEFAULT_SERVICE_NAME; + + this.idName = entityNameToIdName(this.entityName); + + this.rawIndexes = { + primary: null, + all: null, + belongs_to: {}, + other: {}, + }; + + this.attributes = {}; + + // will be populated by build() from rawIndexes + this.indexes = {}; + + // this is not part of the ElectroDB schema spec, but we use it to store reference data + this.references = []; + + this.#initialize(); + } + + #initialize() { + this.addAttribute(this.idName, ID_ATTRIBUTE_DATA); + this.addAttribute('createdAt', CREATED_AT_ATTRIBUTE_DATA); + this.addAttribute('updatedAt', UPDATED_AT_ATTRIBUTE_DATA); + // todo: add createdBy, updatedBy and auto-set from auth context + + // set up the primary index directly + // primary index fields are fixed and known upfront + this.rawIndexes.primary = { + pk: { field: 'pk', composite: [this.idName] }, + sk: { field: 'sk', composite: [] }, + }; + } + + #internalAddIndex(name, partitionKey, sortKey, type) { + const indexFullName = createdIndexName(this.serviceName, this.entityName, name); + + // store index config without assigning fields yet + // the fields will be assigned in build phase based on sorting and presence of "all" index + this.rawIndexes[type][name] = { + ...(indexFullName && { index: indexFullName }), + pk: { ...partitionKey }, + sk: { ...sortKey }, + }; + } + + /** + * Adds a new attribute to the schema definition. + * + * @param {string} name - The attribute name. + * @param {object} data - The attribute definition (type, required, validation, etc.). + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If name is not non-empty or data is not an object. + */ + addAttribute(name, data) { + if (!hasText(name)) { + throw new Error('Attribute name is required and must be non-empty.'); + } + + if (!isNonEmptyObject(data)) { + throw new Error(`Attribute data for "${name}" is required and must be a non-empty object.`); + } + + this.attributes[name] = data; + + return this; + } + + /** + * Adds an "all" index based on composite attributes. + * The "all" index is a special index listing all entities, sorted by given attributes. + * Useful for global queries across all entities of this type. + * Will overwrite any existing "all" index. + * + * @param {...string} attributeNames - The attribute names forming the composite sort key. + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If no attribute names are provided. + */ + addAllIndexWithComposite(...attributeNames) { + if (attributeNames.length === 0) { + throw new Error('At least one composite attribute name is required.'); + } + + this.rawIndexes.all = { + index: createdIndexName(this.serviceName, this.entityName, INDEX_TYPES.ALL), + pk: { field: 'gsi1pk', template: entityNameToAllPKValue(this.entityName) }, + sk: { field: 'gsi1sk', composite: attributeNames }, + }; + + return this; + } + + /** + * Adds an "all" index with a template-based sort key. + * Useful if a single value template defines how entries are sorted. + * + * @param {string} fieldName - The sort key field name. + * @param {string} template - A template string defining how to generate the sort key value. + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If fieldName or template are not valid strings. + */ + addAllIndexWithTemplateField(fieldName, template) { + if (!hasText(fieldName)) { + throw new Error('fieldName is required and must be a non-empty string.'); + } + + if (!hasText(template)) { + throw new Error('template is required and must be a non-empty string.'); + } + + this.rawIndexes.all = { + index: createdIndexName(this.serviceName, this.entityName, 'all'), + pk: { field: 'gsi1pk', template: entityNameToAllPKValue(this.entityName) }, + sk: { field: fieldName, template }, + }; + + return this; + } + + /** + * Adds a generic secondary index (GSI). + * + * @param {string} name - The index name. Cannot be 'primary' or 'all'. + * @param {object} partitionKey - The partition key definition + * (e.g., { composite: [attributeName] }). + * @param {object} sortKey - The sort key definition. + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If index name is reserved or pk/sk configs are invalid. + */ + addIndex(name, partitionKey, sortKey) { + if (!hasText(name)) { + throw new Error('Index name is required and must be a non-empty string.'); + } + + if (RESERVED_INDEX_NAMES.includes(name)) { + throw new Error(`Index name "${name}" is reserved.`); + } + + if (!isNonEmptyObject(partitionKey)) { + throw new Error('Partition key configuration (pk) is required and must be a non-empty object.'); + } + + if (!isNonEmptyObject(sortKey)) { + throw new Error('Sort key configuration (sk) is required and must be a non-empty object.'); + } + + this.#internalAddIndex(name, partitionKey, sortKey, INDEX_TYPES.OTHER); + + return this; + } + + /** + * Adds a reference to another entity, potentially creating a belongs_to index. + * + * @param {string} type - One of Reference.TYPES (BELONGS_TO, HAS_MANY, HAS_ONE). + * @param {string} entityName - The referenced entity name. + * @param {Array} [sortKeys=['updatedAt']] - The attributes to form the sort key. + * @param {object} [options] - Additional reference options. + * @param {boolean} [options.required=true] - Whether the reference is required. Only applies to + * BELONGS_TO references. + * @param {boolean} [options.removeDependents=false] - Whether to remove dependent entities + * on delete. Only applies to HAS_MANY and HAS_ONE references. + * @returns {SchemaBuilder} Returns this builder for method chaining. + * @throws {Error} If type or entityName are invalid. + */ + addReference(type, entityName, sortKeys = [], options = {}) { + if (!Reference.isValidType(type)) { + throw new Error(`Invalid referenceType: "${type}".`); + } + + if (!hasText(entityName)) { + throw new Error('entityName for reference is required and must be a non-empty string.'); + } + const reference = { + type, + target: entityName, + options: { sortKeys }, + }; + + if ([ + Reference.TYPES.HAS_MANY, + Reference.TYPES.HAS_ONE, + ].includes(type)) { + reference.options.removeDependents = options.removeDependents ?? false; + } + + if (type === Reference.TYPES.BELONGS_TO) { + reference.options.required = options.required ?? true; + + // for a BELONGS_TO reference, we add a foreign key attribute + // and a corresponding "belongs_to" index to facilitate lookups by that foreign key. + const foreignKeyName = entityNameToIdName(entityName); + + this.addAttribute(foreignKeyName, { + type: 'string', + required: reference.options.required, + validate: ( + value, + ) => (reference.options.required ? uuidValidate(value) : !value || uuidValidate(value)), + }); + + this.#internalAddIndex( + `by${capitalize(foreignKeyName)}`, + { composite: [decapitalize(foreignKeyName)] }, + { composite: isNonEmptyArray(sortKeys) ? sortKeys : ['updatedAt'] }, + INDEX_TYPES.BELONGS_TO, + ); + } + + this.references.push(Reference.fromJSON(reference)); + + return this; + } + + /** + * Builds the final indexes object by: + * - Sorting and merging belongs_to and other indexes + * - Assigning GSI fields to indexes after final order is determined + * + * @private + */ + #buildIndexes() { + // eslint-disable-next-line camelcase + const { belongs_to, other } = this.rawIndexes; + + // belongs_to indexes come before other indexes + const indexes = { + ...sortIndexes(belongs_to), + ...sortIndexes(other), + }; + + numberGSIsIndexes(indexes, this.rawIndexes.all); + + this.indexes = { + primary: this.rawIndexes.primary, + ...(this.rawIndexes.all && { all: this.rawIndexes.all }), + ...indexes, + }; + } + + /** + * Finalizes the schema by building and ordering indexes. + * + * @returns {object} The fully constructed schema object. + */ + build() { + this.#buildIndexes(); + + return new Schema( + this.modelClass, + this.collectionClass, + { + serviceName: this.serviceName, + schemaVersion: this.schemaVersion, + attributes: this.attributes, + indexes: this.indexes, + references: this.references, + }, + ); + } +} + +export default SchemaBuilder; diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/schema.js b/packages/spacecat-shared-data-access/src/v2/models/base/schema.js new file mode 100644 index 00000000..e6cb3b75 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/base/schema.js @@ -0,0 +1,283 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { hasText, isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import { + classExtends, + entityNameToCollectionName, + entityNameToIdName, + isNonEmptyArray, + isPositiveInteger, + keyNamesToMethodName, + modelNameToEntityName, +} from '../../util/util.js'; + +import BaseCollection from './base.collection.js'; +import BaseModel from './base.model.js'; +import { INDEX_TYPES } from './constants.js'; +import Reference from './reference.js'; + +class Schema { + /** + * Constructs a new Schema instance. + * @constructor + * @param {BaseModel} modelClass - The class representing the model. + * @param {BaseCollection} collectionClass - The class representing the model collection. + * @param {object} rawSchema - The raw schema data. + * @param {string} rawSchema.serviceName - The name of the service. + * @param {number} rawSchema.schemaVersion - The version of the schema. + * @param {object} rawSchema.attributes - The attributes of the schema. + * @param {object} rawSchema.indexes - The indexes of the schema. + * @param {Reference[]} [rawSchema.references] - The references of the schema. + */ + constructor( + modelClass, + collectionClass, + rawSchema, + ) { + this.modelClass = modelClass; + this.collectionClass = collectionClass; + + this.serviceName = rawSchema.serviceName; + this.schemaVersion = rawSchema.schemaVersion; + this.attributes = rawSchema.attributes; + this.indexes = rawSchema.indexes; + this.references = rawSchema.references || []; + + this.#validateSchema(); + } + + #validateSchema() { + if (!classExtends(this.modelClass, BaseModel)) { + throw new Error('Model class must extend BaseModel'); + } + + if (!classExtends(this.collectionClass, BaseCollection)) { + throw new Error('Collection class must extend BaseCollection'); + } + + if (!hasText(this.serviceName)) { + throw new Error('Schema must have a service name'); + } + + if (!isPositiveInteger(this.schemaVersion)) { + throw new Error('Schema version must be a positive integer'); + } + + if (!isNonEmptyObject(this.attributes)) { + throw new Error('Schema must have attributes'); + } + + if (!isNonEmptyObject(this.indexes)) { + throw new Error('Schema must have indexes'); + } + + if (!Array.isArray(this.references)) { + throw new Error('References must be an array'); + } + } + + getAttribute(name) { + return this.attributes[name]; + } + + getAttributes() { + return this.attributes; + } + + getCollectionName() { + return this.collectionClass.name; + } + + getEntityName() { + return modelNameToEntityName(this.getModelName()); + } + + getIdName() { + return entityNameToIdName(this.getModelName()); + } + + /** + * Returns a data structure describing all index-based accessors (like allByX, findByX). + * This can then be used by BaseCollection to create methods without duplicating logic. + * @return {Array<{indexName: string, keySets: string[][]}>} + * Example: [ + * { indexName: 'byOpportunityId', keySets: [['opportunityId'], ['opportunityId','status']] }, + * { indexName: 'byStatusAndCreatedAt', keySets: [['status'],['status','createdAt']] } + * ] + */ + getIndexAccessors() { + const indexes = this.getIndexes([INDEX_TYPES.PRIMARY]); + const result = []; + + Object.keys(indexes).forEach((indexName) => { + const indexKeys = this.getIndexKeys(indexName); + + if (!isNonEmptyArray(indexKeys)) return; + + const keySets = []; + for (let i = 1; i <= indexKeys.length; i += 1) { + keySets.push(indexKeys.slice(0, i)); + } + + result.push({ indexName, keySets }); + }); + + return result; + } + + getIndexByName(indexName) { + return this.indexes[indexName]; + } + + /** + * Returns the indexes for the schema. By default, this returns all indexes. + * You can use the `exclude` parameter to exclude certain indexes. + * @param {Array} [exclude] - One of the INDEX_TYPES values. + * @return {object} The indexes. + */ + getIndexes(exclude) { + if (!Array.isArray(exclude)) { + return this.indexes; + } + + return Object.keys(this.indexes).reduce((acc, indexName) => { + const index = this.indexes[indexName]; + + if (!exclude.includes(indexName)) { + acc[indexName] = index; + } + + return acc; + }, {}); + } + + getIndexKeys(indexName) { + const index = this.getIndexByName(indexName); + + if (!isNonEmptyObject(index)) { + return []; + } + + const pkKeys = Array.isArray(index.pk?.facets) ? index.pk.facets : []; + const skKeys = Array.isArray(index.sk?.facets) ? index.sk.facets : [index.sk?.field]; + + return [...pkKeys, ...skKeys]; + } + + getModelClass() { + return this.modelClass; + } + + getModelName() { + return this.modelClass.name; + } + + /** + * Given a type and a target model name, returns the reciprocal reference if it exists. + * For example, if we have a has_many reference from Foo to Bar, this method can help find + * the belongs_to reference in Bar that points back to Foo. + * @param {EntityRegistry} registry - The entity registry. + * @param {Reference} reference - The reference to find the reciprocal for. + * @return {Reference|null} - The reciprocal reference or null if not found. + */ + getReciprocalReference(registry, reference) { + const target = reference.getTarget(); + const type = reference.getType(); + + if (type !== Reference.TYPES.HAS_MANY) { + return null; + } + + const targetSchema = registry.getCollection(entityNameToCollectionName(target)).schema; + + return targetSchema.getReferenceByTypeAndTarget( + Reference.TYPES.BELONGS_TO, + this.getModelName(), + ); + } + + getReferences() { + return this.references; + } + + getReferencesByType(type) { + return this.references.filter((ref) => ref.type === type); + } + + getReferenceByTypeAndTarget(type, target) { + return this.references.find((ref) => ref.type === type && ref.target === target); + } + + getServiceName() { + return this.serviceName; + } + + getVersion() { + return this.schemaVersion; + } + + toAccessorConfigs(entity, log) { + const indexAccessors = this.getIndexAccessors(); + const accessorConfigs = []; + + indexAccessors.forEach(({ indexName, keySets }) => { + // generate a method for each prefix of the keySets array + // for example, if keySets = ['opportunityId', 'status'], we create: + // allByOpportunityId(...) + // findByOpportunityId(...) + // allByOpportunityIdAndStatus(...) + // findByOpportunityIdAndStatus(...) + keySets.forEach((subset) => { + accessorConfigs.push({ + context: entity, + collection: entity, + name: keyNamesToMethodName(subset, 'allBy'), + requiredKeys: subset, + all: true, + }); + + accessorConfigs.push({ + context: entity, + collection: entity, + name: keyNamesToMethodName(subset, 'findBy'), + requiredKeys: subset, + }); + + log.info(`Created accessors for index [${indexName}] with keys [${subset.join(', ')}]`); + }); + }); + + return accessorConfigs; + } + + /** + * Transforms the stored schema model into a format directly usable by ElectroDB. + * Here, you could do any final adjustments or transformations needed before returning. + * + * @returns {object} ElectroDB-compatible schema. + */ + toElectroDBSchema() { + return { + model: { + entity: this.getModelName(), + version: String(this.getVersion()), + service: this.getServiceName(), + }, + attributes: this.attributes, + indexes: this.indexes, + }; + } +} + +export default Schema; diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.collection.js b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.collection.js new file mode 100755 index 00000000..5914d44b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.collection.js @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { incrementVersion, sanitizeIdAndAuditFields } from '../../util/util.js'; +import BaseCollection from '../base/base.collection.js'; + +/** + * ConfigurationCollection - A collection class responsible for managing Configuration entities. + * Extends the BaseCollection to provide specific methods for interacting with + * Configuration records. + * + * @class ConfigurationCollection + * @extends BaseCollection + */ +class ConfigurationCollection extends BaseCollection { + async create(data) { + const latestConfiguration = await this.findLatest(); + const version = latestConfiguration ? incrementVersion(latestConfiguration.getVersion()) : 1; + const sanitizedData = sanitizeIdAndAuditFields('Organization', data); + sanitizedData.version = version; + + return super.create(sanitizedData); + } + + async findLatest() { + return this.findByAll({}, { order: 'desc' }); + } +} + +export default ConfigurationCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.model.js b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.model.js new file mode 100644 index 00000000..dcecc626 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.model.js @@ -0,0 +1,160 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import { sanitizeIdAndAuditFields } from '../../util/util.js'; +import BaseModel from '../base/base.model.js'; + +/** + * Configuration - A class representing an Configuration entity. + * Provides methods to access and manipulate Configuration-specific data. + * + * @class Configuration + * @extends BaseModel + */ +class Configuration extends BaseModel { + // add your custom methods or overrides here + + getHandler(type) { + return this.getHandlers()?.[type]; + } + + addHandler = (type, handlerData) => { + const handlers = this.getHandlers() || {}; + handlers[type] = { ...handlerData }; + + this.setHandlers(handlers); + }; + + getSlackRoleMembersByRole(role) { + return this.getSlackRoles()?.[role] || []; + } + + getEnabledSiteIdsForHandler(type) { + return this.getHandler(type)?.enabled?.sites || []; + } + + isHandlerEnabledForSite(type, site) { + const handler = this.getHandlers()?.[type]; + if (!handler) return false; + + const siteId = site.getId(); + const orgId = site.getOrganizationId(); + + if (handler.enabled) { + const sites = handler.enabled.sites || []; + const orgs = handler.enabled.orgs || []; + return sites.includes(siteId) || orgs.includes(orgId); + } + + if (handler.disabled) { + const sites = handler.disabled.sites || []; + const orgs = handler.disabled.orgs || []; + return !(sites.includes(siteId) || orgs.includes(orgId)); + } + + return handler.enabledByDefault; + } + + isHandlerEnabledForOrg(type, org) { + const handler = this.getHandlers()?.[type]; + if (!handler) return false; + + const orgId = org.getId(); + + if (handler.enabled) { + return handler.enabled.orgs?.includes(orgId); + } + + if (handler.disabled) { + return !handler.disabled.orgs?.includes(orgId); + } + + return handler.enabledByDefault; + } + + #updatedHandler(type, entityId, enabled, entityKey) { + const handlers = this.getHandlers(); + const handler = handlers?.[type]; + + if (!isNonEmptyObject(handler)) return; + + if (!isNonEmptyObject(handler.disabled)) { + handler.disabled = { orgs: [], sites: [] }; + } + + if (!isNonEmptyObject(handler.enabled)) { + handler.enabled = { orgs: [], sites: [] }; + } + + if (enabled) { + if (handler.enabledByDefault) { + handler.disabled[entityKey] = handler.disabled[entityKey] + .filter((id) => id !== entityId) || []; + } else { + handler.enabled[entityKey] = Array + .from(new Set([...(handler.enabled[entityKey] || []), entityId])); + } + } else if (handler.enabledByDefault) { + handler.disabled[entityKey] = Array + .from(new Set([...(handler.disabled[entityKey] || []), entityId])); + } else { + handler.enabled[entityKey] = handler.enabled[entityKey].filter((id) => id !== entityId) || []; + } + + handlers[type] = handler; + this.setHandlers(handlers); + } + + updateHandlerOrgs(type, orgId, enabled) { + this.#updatedHandler(type, orgId, enabled, 'orgs'); + } + + updateHandlerSites(type, siteId, enabled) { + this.#updatedHandler(type, siteId, enabled, 'sites'); + } + + enableHandlerForSite(type, site) { + const siteId = site.getId(); + if (this.isHandlerEnabledForSite(type, site)) return; + + this.updateHandlerSites(type, siteId, true); + } + + enableHandlerForOrg(type, org) { + const orgId = org.getId(); + if (this.isHandlerEnabledForOrg(type, org)) return; + + this.updateHandlerOrgs(type, orgId, true); + } + + disableHandlerForSite(type, site) { + const siteId = site.getId(); + if (!this.isHandlerEnabledForSite(type, site)) return; + + this.updateHandlerSites(type, siteId, false); + } + + disableHandlerForOrg(type, org) { + const orgId = org.getId(); + if (!this.isHandlerEnabledForOrg(type, org)) return; + + this.updateHandlerOrgs(type, orgId, false); + } + + async save() { + return this.collection.create(sanitizeIdAndAuditFields(this.constructor.name, this.toJSON())); + } +} + +export default Configuration; diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.schema.js b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.schema.js new file mode 100755 index 00000000..be30795d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/configuration.schema.js @@ -0,0 +1,103 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import Joi from 'joi'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Configuration from './configuration.model.js'; +import ConfigurationCollection from './configuration.collection.js'; + +const handlerSchema = Joi.object().pattern(Joi.string(), Joi.object( + { + enabled: Joi.object({ + sites: Joi.array().items(Joi.string()), + orgs: Joi.array().items(Joi.string()), + }), + disabled: Joi.object({ + sites: Joi.array().items(Joi.string()), + orgs: Joi.array().items(Joi.string()), + }), + enabledByDefault: Joi.boolean().required(), + dependencies: Joi.array().items(Joi.object( + { + handler: Joi.string(), + actions: Joi.array().items(Joi.string()), + }, + )), + }, +)).unknown(true); + +const jobsSchema = Joi.array().required(); + +const queueSchema = Joi.object().required(); + +const configurationSchema = Joi.object({ + version: Joi.number().required(), + queues: queueSchema, + handlers: handlerSchema, + jobs: jobsSchema, +}).unknown(true); + +export const checkConfiguration = (data, schema = configurationSchema) => { + const { error, value } = schema.validate(data); + + if (error) { + throw new Error(`Configuration validation error: ${error.message}`); + } + + return value; +}; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(Configuration, ConfigurationCollection) + .addAttribute('handlers', { + type: 'any', + validate: (value) => !value || checkConfiguration(value, handlerSchema), + }) + .addAttribute('jobs', { + type: 'list', + items: { + type: 'map', + properties: { + group: { type: ['audits', 'imports', 'reports'] }, + type: { type: 'string', required: true }, + interval: { type: ['daily', 'weekly'] }, + }, + }, + }) + .addAttribute('queues', { + type: 'any', + required: true, + validate: (value) => isNonEmptyObject(value), + }) + .addAttribute('slackRoles', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAttribute('version', { + type: 'number', + required: true, + readOnly: true, + }) + // eslint-disable-next-line no-template-curly-in-string + .addAllIndexWithTemplateField('version', '${version}'); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/configuration/index.d.ts new file mode 100644 index 00000000..2f902dcc --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/index.d.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { + BaseCollection, BaseModel, Organization, Site, +} from '../index'; + +export interface Configuration extends BaseModel { + /** + * Retrieves the configuration version. + * @returns {number} The configuration version. + */ + getVersion: () => number; + + /** + * Retrieves the queues configuration. + * @returns {object} The queues configuration. + */ + getQueues: () => object; + + /** + * Retrieves the jobs configuration. + * @returns {Array} The jobs configurations. + */ + getJobs: () => Array; + + /** + * Retrieves the handlers configuration. + * @returns {object} The handlers configuration. + */ + getHandlers: () => object; + + /** + * Retrieves the handler configuration for handler type. + * @param type The handler type. + * @returns {object} The handler type configuration. + */ + getHandler: (type) => object; + + /** + * Retrieves the slack roles configuration. + * @returns {object} The slack roles configuration. + */ + getSlackRoles: () => object; + + /** + * Return true if a handler type is enabled for an organization. + * @param type handler type + * @param org organization + */ + isHandlerEnabledForOrg: (type: string, org: Organization) => boolean; + + /** + * Return true if a handler type is enabled for a site. + * @param type handler type + * @param site site + */ + isHandlerEnabledForSite: (type: string, site: Site) => boolean; + + /** + * Enables a handler type for an site. + * @param type handler type + * @param site site + */ + enableHandlerForSite: (type: string, site: Site) => void; + + /** + * Enables a handler type for an organization. + * @param type handler type + * @param org organization + */ + enableHandlerForOrg: (type: string, org: Organization) => void; + + /** + * Disables a handler type for an site. + * @param type handler type + * @param site site + */ + disableHandlerForSite: (type: string, site: Site) => void; + + /** + * Disables a handler type for an organization. + * @param type handler type + * @param org organization + */ + disableHandlerForOrg: (type:string, org: Organization) => void; +} + +export interface ConfigurationCollection extends BaseCollection { + /** + * Retrieves the latest configuration by version. + * @returns {Configuration} The configuration. + */ + findLatest: () => Configuration; + + /** + * Retrieves the configuration by version. + * @param version The configuration version. + * @returns {Configuration} The configuration. + */ + findByVersion: (version: number) => Configuration; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/configuration/index.js b/packages/spacecat-shared-data-access/src/v2/models/configuration/index.js new file mode 100644 index 00000000..c8704d91 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/configuration/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import Configuration from './configuration.model.js'; +import ConfigurationCollection from './configuration.collection.js'; + +export { + Configuration, + ConfigurationCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.collection.js b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.collection.js new file mode 100755 index 00000000..2401c56a --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.collection.js @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * ExperimentCollection - A collection class responsible for managing Experiment entities. + * Extends the BaseCollection to provide specific methods for interacting with Experiment records. + * + * @class ExperimentCollection + * @extends BaseCollection + */ +class ExperimentCollection extends BaseCollection { + // add custom methods here +} + +export default ExperimentCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.model.js b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.model.js new file mode 100755 index 00000000..0e9b0e96 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.model.js @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +export const DEFAULT_UPDATED_BY = 'spacecat'; + +/** + * Experiment - A class representing an Experiment entity. + * Provides methods to access and manipulate Experiment-specific data. + * + * @class Experiment + * @extends BaseModel + */ +class Experiment extends BaseModel { + // add your custom methods or overrides here +} + +export default Experiment; diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.schema.js b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.schema.js new file mode 100644 index 00000000..22c219dc --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/experiment.schema.js @@ -0,0 +1,70 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isIsoDate, isNonEmptyObject, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Experiment, { DEFAULT_UPDATED_BY } from './experiment.model.js'; +import ExperimentCollection from './experiment.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(Experiment, ExperimentCollection) + .addReference('belongs_to', 'Site', ['expId', 'url', 'updatedAt']) + .addAttribute('conversionEventName', { + type: 'string', + }) + .addAttribute('conversionEventValue', { type: 'string' }) + .addAttribute('endDate', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('expId', { + type: 'string', + required: true, + }) + .addAttribute('name', { type: 'string' }) + .addAttribute('startDate', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('status', { + type: ['ACTIVE', 'INACTIVE'], + required: true, + }) + .addAttribute('type', { type: 'string' }) + .addAttribute('url', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('updatedBy', { + type: 'string', + required: true, + default: DEFAULT_UPDATED_BY, + }) + .addAttribute('variants', { + type: 'list', + items: { + type: 'any', + validate: (value) => isNonEmptyObject(value), + }, + required: true, + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/experiment/index.d.ts new file mode 100644 index 00000000..b43b3996 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/index.d.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel, Site } from '../index'; + +export interface Experiment extends BaseModel { + getConversionEventName(): string; + getConversionEventValue(): string; + getEndDate(): number; + getExpId(): string; + getName(): string; + getSite(): Promise; + getSiteId(): string; + getStartDate(): number; + getStatus(): string; + getType(): string; + getUrl(): string; + getVariants(): object; + setConversionEventName(conversionEventName: string): Experiment; + setConversionEventValue(conversionEventValue: string): Experiment; + setEndDate(endDate: number): Experiment; + setExpId(expId: string): Experiment; + setName(name: string): Experiment; + setStartDate(startDate: number): Experiment; + setStatus(status: string): Experiment; + setType(type: string): Experiment; + setUrl(url: string): Experiment; + setVariants(variants: object): Experiment; +} + +export interface ExperimentCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteIdAndExpId(siteId: string, expId: string): Promise; + findBySiteIdAndExpId(siteId: string, expId: string): Promise; + findBySiteIdAndExpIdAndUrl( + siteId: string, + expId: string, + url: string, + ): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/experiment/index.js b/packages/spacecat-shared-data-access/src/v2/models/experiment/index.js new file mode 100644 index 00000000..4324ba7e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/experiment/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import Experiment from './experiment.model.js'; +import ExperimentCollection from './experiment.collection.js'; + +export { + Experiment, + ExperimentCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.collection.js b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.collection.js new file mode 100644 index 00000000..d6069afc --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.collection.js @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import BaseCollection from '../base/base.collection.js'; +import { ValidationError } from '../../errors/index.js'; + +/** + * ImportJobCollection - A collection class responsible for managing ImportJob entities. + * Extends the BaseCollection to provide specific methods for interacting with ImportJob records. + * + * @class ImportJobCollection + * @extends BaseCollection + */ +class ImportJobCollection extends BaseCollection { + async allByDateRange(startDate, endDate) { + if (!isIsoDate(startDate)) { + throw new ValidationError(`Invalid start date: ${startDate}`); + } + + if (!isIsoDate(endDate)) { + throw new ValidationError(`Invalid end date: ${endDate}`); + } + + return this.all({}, { + between: { + attribute: 'startedAt', + start: startDate, + end: endDate, + }, + }); + } +} + +export default ImportJobCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.model.js b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.model.js new file mode 100644 index 00000000..5bf64f4b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.model.js @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +/** + * Supported Import Options. + */ +export const ImportOptions = { + ENABLE_JAVASCRIPT: 'enableJavascript', + PAGE_LOAD_TIMEOUT: 'pageLoadTimeout', +}; + +/** + * Import Job Status types. + * Any changes to this object needs to be reflected in the index.d.ts file as well. + */ +export const ImportJobStatus = { + RUNNING: 'RUNNING', + COMPLETE: 'COMPLETE', + FAILED: 'FAILED', + STOPPED: 'STOPPED', +}; + +/** + * ImportURL Status types. + * Any changes to this object needs to be reflected in the index.d.ts file as well. + */ +export const ImportUrlStatus = { + PENDING: 'PENDING', + REDIRECT: 'REDIRECT', + ...ImportJobStatus, +}; + +/** + * ImportJob - A class representing an ImportJob entity. + * Provides methods to access and manipulate ImportJob-specific data. + * + * @class ImportJob + * @extends BaseModel + */ +class ImportJob extends BaseModel { + // add your custom methods or overrides here +} + +export default ImportJob; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.schema.js b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.schema.js new file mode 100755 index 00000000..055af993 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/import-job.schema.js @@ -0,0 +1,152 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { + isInteger, + isIsoDate, + isNumber, + isObject, + isValidUrl, +} from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import ImportJob, { ImportJobStatus, ImportOptions } from './import-job.model.js'; +import ImportJobCollection from './import-job.collection.js'; + +const ImportOptionTypeValidator = { + [ImportOptions.ENABLE_JAVASCRIPT]: (value) => { + if (value !== true && value !== false) { + throw new Error(`Invalid value for ${ImportOptions.ENABLE_JAVASCRIPT}: ${value}`); + } + }, + [ImportOptions.PAGE_LOAD_TIMEOUT]: (value) => { + if (!isInteger(value) || value < 0) { + throw new Error(`Invalid value for ${ImportOptions.PAGE_LOAD_TIMEOUT}: ${value}`); + } + }, +}; + +const validateOptions = (options) => { + if (!isObject(options)) { + throw new Error(`Invalid options: ${options}`); + } + + const invalidOptions = Object.keys(options).filter( + (key) => !Object.values(ImportOptions) + .some((value) => value.toLowerCase() === key.toLowerCase()), + ); + + if (invalidOptions.length > 0) { + throw new Error(`Invalid options: ${invalidOptions}`); + } + + // validate each option for it's expected data type + Object.keys(options).forEach((key) => { + if (ImportOptionTypeValidator[key]) { + ImportOptionTypeValidator[key](options[key]); + } + }); + + return true; +}; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(ImportJob, ImportJobCollection) + .addReference('has_many', 'ImportUrls') + .addAttribute('baseURL', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('duration', { + type: 'number', + default: 0, + validate: (value) => !value || isNumber(value), + }) + .addAttribute('endedAt', { + type: 'string', + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('failedCount', { + type: 'number', + default: 0, + validate: (value) => !value || isInteger(value), + }) + .addAttribute('hasCustomHeaders', { + type: 'boolean', + default: false, + }) + .addAttribute('hasCustomImportJs', { + type: 'boolean', + default: false, + }) + .addAttribute('hashedApiKey', { + type: 'string', + required: true, + }) + .addAttribute('importQueueId', { + type: 'string', + }) + .addAttribute('initiatedBy', { + type: 'map', + properties: { + apiKeyName: { type: 'string' }, + imsOrgId: { type: 'string' }, + imsUserId: { type: 'string' }, + userAgent: { type: 'string' }, + }, + }) + .addAttribute('options', { + type: 'any', + validate: (value) => !value || validateOptions(value), + }) + .addAttribute('redirectCount', { + type: 'number', + default: 0, + validate: (value) => !value || isInteger(value), + }) + .addAttribute('status', { + type: Object.values(ImportJobStatus), + required: true, + }) + .addAttribute('startedAt', { + type: 'string', + required: true, + readOnly: true, + default: () => new Date().toISOString(), + validate: (value) => isIsoDate(value), + }) + .addAttribute('successCount', { + type: 'number', + default: 0, + validate: (value) => !value || isInteger(value), + }) + .addAttribute('urlCount', { + type: 'number', + default: 0, + validate: (value) => !value || isInteger(value), + }) + .addAllIndexWithComposite('startedAt') + .addIndex( + 'byStatus', + { composite: ['status'] }, + { composite: ['updatedAt'] }, + ); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/import-job/index.d.ts new file mode 100644 index 00000000..3cb36778 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/index.d.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel } from '../base'; + +export interface ImportJob extends BaseModel { + getBaseURL(): string, + getDuration(): number, + getEndedAt(): number, + getFailedCount(): number, + getHasCustomHeaders(): boolean, + getHasCustomImportJs(): boolean, + getHashedApiKey(): string, + getImportQueueId(): string, + getInitiatedBy(): string, + getOptions(): string, + getRedirectCount(): number, + getStatus(): string, + getStartedAt(): number, + getSuccessCount(): number, + getUrlCount(): number, + setBaseURL(baseURL: string): void, + setDuration(duration: number): void, + setEndedAt(endTime: number): void, + setFailedCount(failedCount: number): void, + setHasCustomHeaders(hasCustomHeaders: boolean): void, + setHasCustomImportJs(hasCustomImportJs: boolean): void, + setHashedApiKey(hashedApiKey: string): void, + setImportQueueId(importQueueId: string): void, + setInitiatedBy(initiatedBy: string): void, + setOptions(options: string): void, + setRedirectCount(redirectCount: number): void, + setStatus(status: string): void, + setStartedAt(startTime: number): void, + setSuccessCount(successCount: number): void, + setUrlCount(urlCount: number): void, +} + +export interface ImportJobCollection extends BaseCollection { + allByDateRange(startDate: number, endDate: number): Promise; + allByStatus(status: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-job/index.js b/packages/spacecat-shared-data-access/src/v2/models/import-job/index.js new file mode 100644 index 00000000..7ab8ebc0 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-job/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import ImportJob from './import-job.model.js'; +import ImportJobCollection from './import-job.collection.js'; + +export { + ImportJob, + ImportJobCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.collection.js b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.collection.js new file mode 100755 index 00000000..d00b8502 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.collection.js @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * ImportUrlCollection - A collection class responsible for managing ImportUrl entities. + * Extends the BaseCollection to provide specific methods for interacting with ImportUrl records. + * + * @class ImportUrlCollection + * @extends BaseCollection + */ +class ImportUrlCollection extends BaseCollection { + // add custom methods here +} + +export default ImportUrlCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.model.js b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.model.js new file mode 100644 index 00000000..0ca9d9e3 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.model.js @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +export const IMPORT_URL_EXPIRES_IN_DAYS = 30; + +/** + * ImportUrl - A class representing an ImportUrl entity. + * Provides methods to access and manipulate ImportUrl-specific data. + * + * @class ImportUrl + * @extends BaseModel + */ +class ImportUrl extends BaseModel { + // add your custom methods or overrides here +} + +export default ImportUrl; diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.schema.js b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.schema.js new file mode 100644 index 00000000..e1c9728c --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-url/import-url.schema.js @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isIsoDate, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import { ImportUrlStatus } from '../import-job/import-job.model.js'; +import SchemaBuilder from '../base/schema.builder.js'; +import ImportUrl, { IMPORT_URL_EXPIRES_IN_DAYS } from './import-url.model.js'; +import ImportUrlCollection from './import-url.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(ImportUrl, ImportUrlCollection) + .addReference('belongs_to', 'ImportJob', ['status']) + .addAttribute('expiresAt', { + type: 'string', + required: true, + validate: (value) => isIsoDate(value), + default: () => { + const date = new Date(); + date.setDate(date.getDate() + IMPORT_URL_EXPIRES_IN_DAYS); + return date.toISOString(); + }, + }) + .addAttribute('file', { + type: 'string', + }) + .addAttribute('path', { + type: 'string', + }) + .addAttribute('reason', { + type: 'string', + }) + .addAttribute('status', { + type: Object.values(ImportUrlStatus), + required: true, + }) + .addAttribute('url', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/import-url/index.d.ts new file mode 100644 index 00000000..344a4724 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-url/index.d.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel } from '../base'; + +export interface ImportUrl extends BaseModel { + getExpiresAt(): number, + getFile(): string, + getImportJobId(): string, + getPath(): string, + getReason(): string, + getStatus(): string, + getUrl(): string, + setExpiresAt(expiresAt: number): void, + setFile(file: string): void, + setImportJobId(importJobId: string): void, + setPath(path: string): void, + setReason(reason: string): void, + setStatus(status: string): void, + setUrl(url: string): void, +} + +export interface ImportUrlCollection extends BaseCollection { + allByImportJobId(importJobId: string): Promise; + allByImportUrlsByJobIdAndStatus(importJobId: string, status: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/import-url/index.js b/packages/spacecat-shared-data-access/src/v2/models/import-url/index.js new file mode 100644 index 00000000..fa44c022 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/import-url/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import ImportUrl from './import-url.model.js'; +import ImportUrlCollection from './import-url.collection.js'; + +export { + ImportUrl, + ImportUrlCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/index.d.ts index c49c6c8b..06580776 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/index.d.ts +++ b/packages/spacecat-shared-data-access/src/v2/models/index.d.ts @@ -10,102 +10,14 @@ * governing permissions and limitations under the License. */ -import type { ValidationError } from '../index.d.ts'; - -/** - * Interface representing a base model for interacting with a data entity. - */ -export interface BaseModel { - getId(): string; - getCreatedAt(): string; - getUpdatedAt(): string; - remove(): Promise; - save(): Promise; -} - -export interface MultiStatusCreateResult { - createdItems: T[], - errorItems: { item: object, error: ValidationError }[], -} - -/** - * Interface representing an Opportunity model, extending BaseModel. - */ -export interface Opportunity extends BaseModel { /* eslint-disable no-use-before-define */ - addSuggestions(suggestions: object[]): Promise>; - // eslint-disable-next-line no-use-before-define - getSuggestions(): Promise; - getSiteId(): string; - setSiteId(siteId: string): Opportunity; - getAuditId(): string; - setAuditId(auditId: string): Opportunity; - getRunbook(): string; - setRunbook(runbook: string): Opportunity; - getGuidance(): string; - setGuidance(guidance: string): Opportunity; - getTitle(): string; - setTitle(title: string): Opportunity; - getDescription(): string; - setDescription(description: string): Opportunity; - getType(): string; - getStatus(): string; - setStatus(status: string): Opportunity; - getOrigin(): string; - setOrigin(origin: string): Opportunity; - getTags(): string[]; - setTags(tags: string[]): Opportunity; - getData(): object; - setData(data: object): Opportunity; -} - -/** - * Interface representing a Suggestion model, extending BaseModel. - */ -export interface Suggestion extends BaseModel { - getOpportunity(): Promise; - getOpportunityId(): string; - setOpportunityId(opportunityId: string): Suggestion; - getType(): string; - getStatus(): string; - setStatus(status: string): Suggestion; - getRank(): number; - setRank(rank: number): Suggestion; - getData(): object; - setData(data: object): Suggestion; - getKpiDeltas(): object; - setKpiDeltas(kpiDeltas: object): Suggestion; -} - -/** - * Interface representing a base collection for interacting with data entities. - */ -export interface BaseCollection { - findById(id: string): Promise; - findByIndexKeys(indexKeys: object): Promise; - create(item: object): Promise; - createMany(items: object[]): Promise>; -} - -/** - * Interface representing the Opportunity collection, extending BaseCollection. - */ -export interface OpportunityCollection extends BaseCollection { - allBySiteId(siteId: string): Promise; - allBySiteIdAndStatus(siteId: string, status: string): Promise; -} - -/** - * Interface representing the Suggestion collection, extending BaseCollection. - */ -export interface SuggestionCollection extends BaseCollection { - allByOpportunityId(opportunityId: string): Promise; - allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; - bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise; -} - -/** - * Interface representing the Model Factory for creating and managing model collections. - */ -export interface ModelFactory { - getCollection(collectionName: string): BaseCollection; -} +export type * from './audit/index.d.ts'; +export type * from './configuration/index.d.ts'; +export type * from './base/index.d.ts'; +export type * from './experiment/index.d.ts'; +export type * from './key-event/index.d.ts'; +export type * from './opportunity/index.d.ts'; +export type * from './organization/index.d.ts'; +export type * from './site/index.d.ts'; +export type * from './site-candidate/index.d.ts'; +export type * from './site-top-page/index.d.ts'; +export type * from './suggestion/index.d.ts'; diff --git a/packages/spacecat-shared-data-access/src/v2/models/index.js b/packages/spacecat-shared-data-access/src/v2/models/index.js old mode 100644 new mode 100755 index d3948b33..839b609e --- a/packages/spacecat-shared-data-access/src/v2/models/index.js +++ b/packages/spacecat-shared-data-access/src/v2/models/index.js @@ -10,18 +10,17 @@ * governing permissions and limitations under the License. */ -import ModelFactory from './model.factory.js'; -import BaseModel from './base.model.js'; -import Opportunity from './opportunity.model.js'; -import OpportunityCollection from './opportunity.collection.js'; -import Suggestion from './suggestion.model.js'; -import SuggestionCollection from './suggestion.collection.js'; - -export { - ModelFactory, - BaseModel, - Opportunity, - OpportunityCollection, - Suggestion, - SuggestionCollection, -}; +export * from './api-key/index.js'; +export * from './audit/index.js'; +export * from './base/index.js'; +export * from './configuration/index.js'; +export * from './experiment/index.js'; +export * from './import-job/index.js'; +export * from './import-url/index.js'; +export * from './key-event/index.js'; +export * from './opportunity/index.js'; +export * from './organization/index.js'; +export * from './site-candidate/index.js'; +export * from './site-top-page/index.js'; +export * from './site/index.js'; +export * from './suggestion/index.js'; diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/key-event/index.d.ts new file mode 100644 index 00000000..d8b02285 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/index.d.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel } from '../index'; + +export interface KeyEvent extends BaseModel { + getName(): string; + getSiteId(): string; + getTime(): number; + getType(): string; + setName(name: string): KeyEvent; + setSiteId(siteId: string): KeyEvent; + setTime(time: number): KeyEvent; + setType(type: string): KeyEvent; +} + +export interface KeyEventCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/index.js b/packages/spacecat-shared-data-access/src/v2/models/key-event/index.js new file mode 100644 index 00000000..5fb38ce4 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import KeyEvent from './key-event.model.js'; +import KeyEventCollection from './key-event.collection.js'; + +export { + KeyEvent, + KeyEventCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.collection.js b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.collection.js new file mode 100644 index 00000000..54a3859b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.collection.js @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * KeyEventCollection - A collection class responsible for managing KeyEvent entities. + * Extends the BaseCollection to provide specific methods for interacting with KeyEvent records. + * + * @class KeyEventCollection + * @extends BaseCollection + */ +class KeyEventCollection extends BaseCollection { + // add custom methods here +} + +export default KeyEventCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.model.js b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.model.js new file mode 100755 index 00000000..08ec1af1 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.model.js @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +export const KEY_EVENT_TYPES = { + PERFORMANCE: 'PERFORMANCE', + SEO: 'SEO', + CONTENT: 'CONTENT', + CODE: 'CODE', + THIRD_PARTY: 'THIRD PARTY', + EXPERIMENTATION: 'EXPERIMENTATION', + NETWORK: 'NETWORK', + STATUS_CHANGE: 'STATUS CHANGE', +}; + +/** + * KeyEvent - A class representing an KeyEvent entity. + * Provides methods to access and manipulate KeyEvent-specific data. + * + * @class KeyEvent + * @extends BaseModel + */ +class KeyEvent extends BaseModel { + // add your custom methods or overrides here +} + +export default KeyEvent; diff --git a/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.schema.js b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.schema.js new file mode 100644 index 00000000..d28afc74 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/key-event/key-event.schema.js @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { hasText, isIsoDate } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import { KEY_EVENT_TYPES } from '../../../models/key-event.js'; +import KeyEvent from './key-event.model.js'; +import KeyEventCollection from './key-event.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(KeyEvent, KeyEventCollection) + .addReference('belongs_to', 'Site', ['time']) + .addAttribute('name', { + type: 'string', + required: true, + validate: (value) => hasText(value), + }) + .addAttribute('type', { + type: Object.values(KEY_EVENT_TYPES), + required: true, + }) + .addAttribute('time', { + type: 'string', + required: true, + validate: (value) => isIsoDate(value), + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/model.factory.js b/packages/spacecat-shared-data-access/src/v2/models/model.factory.js deleted file mode 100644 index d6c3b5ac..00000000 --- a/packages/spacecat-shared-data-access/src/v2/models/model.factory.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import OpportunityCollection from './opportunity.collection.js'; -import SuggestionCollection from './suggestion.collection.js'; - -/** - * ModelFactory - A factory class responsible for creating and managing collections - * of different models. This class serves as a centralized point for accessing and - * instantiating model collections. - * - * @class ModelFactory - */ -class ModelFactory { - /** - * Constructs an instance of ModelFactory. - * @constructor - * @param {Object} service - The ElectroDB service instance used to manage entities. - * @param {Object} logger - A logger for capturing and logging information. - */ - constructor(service, logger) { - this.service = service; - this.logger = logger; - this.models = new Map(); - - this.initialize(); - } - - /** - * Initializes the collections managed by the ModelFactory. - * This method creates instances of each collection and stores them in an internal map. - * @private - */ - initialize() { - const opportunityCollection = new OpportunityCollection( - this.service, - this, - this.logger, - ); - const suggestionCollection = new SuggestionCollection( - this.service, - this, - this.logger, - ); - - this.models.set(OpportunityCollection.name, opportunityCollection); - this.models.set(SuggestionCollection.name, suggestionCollection); - } - - /** - * Gets a collection instance by its name. - * @param {string} collectionName - The name of the collection to retrieve. - * @returns {Object} - The requested collection instance. - * @throws {Error} - Throws an error if the collection with the specified name is not found. - */ - getCollection(collectionName) { - const collection = this.models.get(collectionName); - if (!collection) { - throw new Error(`Collection ${collectionName} not found`); - } - return collection; - } -} - -export default ModelFactory; diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity.collection.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity.collection.js deleted file mode 100644 index 03b6012c..00000000 --- a/packages/spacecat-shared-data-access/src/v2/models/opportunity.collection.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { hasText } from '@adobe/spacecat-shared-utils'; - -import BaseCollection from './base.collection.js'; -import Opportunity from './opportunity.model.js'; - -/** - * OpportunityCollection - A collection class responsible for managing Opportunity entities. - * Extends the BaseCollection to provide specific methods for interacting with Opportunity records. - * - * @class OpportunityCollection - * @extends BaseCollection - */ -class OpportunityCollection extends BaseCollection { - /** - * Constructs an instance of OpportunityCollection. Tells the base class which model to use. - * @constructor - * @param {Object} service - The ElectroDB service instance used to manage Opportunity entities. - * @param {Object} modelFactory - A factory for creating model instances. - * @param {Object} log - A logger for capturing logging information. - */ - constructor(service, modelFactory, log) { - super(service, modelFactory, Opportunity, log); - } - - /** - * Retrieves all Opportunity entities by their associated site ID. - * @async - * @param {string} siteId - The unique identifier of the site. - * @returns {Promise>} - A promise that resolves to an array of - * Opportunity instances related to the given site ID. - * @throws {Error} - Throws an error if the siteId is not provided or if the query fails. - */ - async allBySiteId(siteId) { - if (!hasText(siteId)) { - throw new Error('SiteId is required'); - } - return this.findByIndexKeys({ siteId }); - } - - /** - * Retrieves all Opportunity entities by their associated site ID and status. - * @param {string} siteId - The unique identifier of the site. - * @param {string} status - The status of the Opportunity entities to retrieve. - * @return {Promise>} - A promise that resolves to an array of - * Opportunity instances. - * @throws {Error} - Throws an error if the siteId or status is not provided or if the - * query fails. - */ - async allBySiteIdAndStatus(siteId, status) { - if (!hasText(siteId)) { - throw new Error('SiteId is required'); - } - - if (!hasText(status)) { - throw new Error('Status is required'); - } - - return this.findByIndexKeys({ siteId, status }); - } -} - -export default OpportunityCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.d.ts new file mode 100644 index 00000000..cc7afa6e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.d.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { + BaseCollection, BaseModel, MultiStatusCreateResult, Suggestion, +} from '../index'; + +export interface Opportunity extends BaseModel { + addSuggestions(suggestions: object[]): Promise>; + getAuditId(): string; + getData(): object; + getDescription(): string; + getGuidance(): string; + getOrigin(): string; + getRunbook(): string; + getSiteId(): string; + getStatus(): string; + getSuggestions(): Promise; + getTags(): string[]; + getTitle(): string; + getType(): string; + setAuditId(auditId: string): Opportunity; + setData(data: object): Opportunity; + setDescription(description: string): Opportunity; + setGuidance(guidance: string): Opportunity; + setOrigin(origin: string): Opportunity; + setRunbook(runbook: string): Opportunity; + setSiteId(siteId: string): Opportunity; + setStatus(status: string): Opportunity; + setTags(tags: string[]): Opportunity; + setTitle(title: string): Opportunity; +} + +export interface OpportunityCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteIdAndStatus(siteId: string, status: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.js new file mode 100644 index 00000000..ffce3d09 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import Opportunity from './opportunity.model.js'; +import OpportunityCollection from './opportunity.collection.js'; + +export { + Opportunity, + OpportunityCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.collection.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.collection.js new file mode 100644 index 00000000..28619e21 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.collection.js @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * OpportunityCollection - A collection class responsible for managing Opportunity entities. + * Extends the BaseCollection to provide specific methods for interacting with Opportunity records. + * + * @class OpportunityCollection + * @extends BaseCollection + */ +class OpportunityCollection extends BaseCollection { + // add custom methods here +} + +export default OpportunityCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity.model.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.model.js similarity index 85% rename from packages/spacecat-shared-data-access/src/v2/models/opportunity.model.js rename to packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.model.js index 0bc713a9..47a5b9d5 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/opportunity.model.js +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.model.js @@ -10,7 +10,20 @@ * governing permissions and limitations under the License. */ -import BaseModel from './base.model.js'; +import BaseModel from '../base/base.model.js'; + +export const ORIGINS = { + ESS_OPS: 'ESS_OPS', + AI: 'AI', + AUTOMATION: 'AUTOMATION', +}; + +export const STATUSES = { + NEW: 'NEW', + IN_PROGRESS: 'IN_PROGRESS', + IGNORED: 'IGNORED', + RESOLVED: 'RESOLVED', +}; /** * Opportunity - A class representing an Opportunity entity. @@ -38,7 +51,7 @@ class Opportunity extends BaseModel { ...suggestion, [this.idName]: this.getId(), })); - return this.modelFactory + return this.entityRegistry .getCollection('SuggestionCollection') .createMany(childSuggestions, this); } diff --git a/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.schema.js b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.schema.js new file mode 100644 index 00000000..53d29516 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/opportunity/opportunity.schema.js @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isNonEmptyObject, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Opportunity, { ORIGINS, STATUSES } from './opportunity.model.js'; +import OpportunityCollection from './opportunity.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(Opportunity, OpportunityCollection) + .addReference('belongs_to', 'Site', ['status', 'updatedAt']) + .addReference('belongs_to', 'Audit', ['updatedAt'], { required: false }) + .addReference('has_many', 'Suggestions', ['updatedAt'], { removeDependents: true }) + .addAttribute('runbook', { + type: 'string', + validate: (value) => !value || isValidUrl(value), + }) + .addAttribute('type', { + type: 'string', + readOnly: true, + required: true, + }) + .addAttribute('data', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAttribute('origin', { + type: Object.values(ORIGINS), + required: true, + }) + .addAttribute('title', { + type: 'string', + required: true, + }) + .addAttribute('description', { + type: 'string', + }) + .addAttribute('status', { + type: Object.values(STATUSES), + required: true, + default: 'NEW', + }) + .addAttribute('guidance', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAttribute('tags', { + type: 'set', + items: 'string', + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/organization/index.d.ts new file mode 100644 index 00000000..4e71f721 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/index.d.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel } from '../index'; + +export interface Organization extends BaseModel { + getConfig(): object; + getFulfillableItems(): object; + getImsOrgId(): string; + getName(): string; + setConfig(config: object): Organization; + setFulfillableItems(fulfillableItems: object): Organization; + setImsOrgId(imsOrgId: string): Organization; + setName(name: string): Organization; +} + +export interface OrganizationCollection extends BaseCollection { + allByImsOrgId(imsOrgId: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/index.js b/packages/spacecat-shared-data-access/src/v2/models/organization/index.js new file mode 100644 index 00000000..fcc09f6d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import Organization from './organization.model.js'; +import OrganizationCollection from './organization.collection.js'; + +export { + Organization, + OrganizationCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/organization.collection.js b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.collection.js new file mode 100644 index 00000000..4e7cae32 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.collection.js @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * OrganizationCollection - A collection class responsible for managing Organization entities. + * Extends the BaseCollection to provide specific methods for interacting with Organization records. + * + * @class OrganizationCollection + * @extends BaseCollection + */ +class OrganizationCollection extends BaseCollection { + // add custom methods here +} + +export default OrganizationCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/organization.model.js b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.model.js new file mode 100755 index 00000000..71c87fd0 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.model.js @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Config } from '../../../models/site/config.js'; +import BaseModel from '../base/base.model.js'; + +/** + * Organization - A class representing an Organization entity. + * Provides methods to access and manipulate Organization-specific data. + * + * @class Organization + * @extends BaseModel + */ +class Organization extends BaseModel { + // add your custom methods or overrides here + + getConfig() { + return Config(this.record.config); + } +} + +export default Organization; diff --git a/packages/spacecat-shared-data-access/src/v2/models/organization/organization.schema.js b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.schema.js new file mode 100644 index 00000000..36b120df --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/organization/organization.schema.js @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import { DEFAULT_CONFIG, validateConfiguration } from '../../../models/site/config.js'; +import SchemaBuilder from '../base/schema.builder.js'; +import Organization from './organization.model.js'; +import OrganizationCollection from './organization.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(Organization, OrganizationCollection) + // this will add an attribute 'organizationId' as well as an index 'byOrganizationId' + .addReference('has_many', 'Sites') + .addAttribute('config', { + type: 'any', + required: true, + default: DEFAULT_CONFIG, + validate: (value) => isNonEmptyObject(validateConfiguration(value)), + }) + .addAttribute('name', { + type: 'string', + required: true, + }) + .addAttribute('imsOrgId', { + type: 'string', + default: 'default', + }) + .addAttribute('fulfillableItems', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAllIndexWithComposite('imsOrgId'); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.d.ts new file mode 100644 index 00000000..330f3352 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.d.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel } from '../index'; + +export interface SiteCandidate extends BaseModel { + getBaseURL(): string; + getHlxConfig(): object; + getSite(): object; + getSiteId(): string; + getSource(): string; + getStatus(): string; + getUpdatedBy(): string; + setBaseURL(baseURL: string): SiteCandidate; + setHlxConfig(hlxConfig: object): SiteCandidate; + setSiteId(siteId: string): SiteCandidate; + setSource(source: string): SiteCandidate; + setStatus(status: string): SiteCandidate; + setUpdatedBy(updatedBy: string): SiteCandidate; +} + +export interface SiteCandidateCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteIdAndSiteCandidateIdAndUrl( + siteId: string, + siteCandidateId: string, + url: string, + ): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.js b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.js new file mode 100644 index 00000000..b030ceee --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import SiteCandidate from './site-candidate.model.js'; +import SiteCandidateCollection from './site-candidate.collection.js'; + +export { + SiteCandidate, + SiteCandidateCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.collection.js b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.collection.js new file mode 100755 index 00000000..f1a08ae2 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.collection.js @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * SiteCandidateCollection - A collection class responsible for managing SiteCandidate entities. + * Extends the BaseCollection to provide specific methods for interacting with + * SiteCandidate records. + * + * @class SiteCandidateCollection + * @extends BaseCollection + */ +class SiteCandidateCollection extends BaseCollection { + // add custom methods here +} + +export default SiteCandidateCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.model.js b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.model.js new file mode 100755 index 00000000..9a18a3c9 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.model.js @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +export const DEFAULT_UPDATED_BY = 'spacecat'; + +export const SITE_CANDIDATE_SOURCES = { + SPACECAT_SLACK_BOT: 'SPACECAT_SLACK_BOT', + RUM: 'RUM', + CDN: 'CDN', +}; + +export const SITE_CANDIDATE_STATUS = { + PENDING: 'PENDING', // site candidate notification sent and waiting for human input + IGNORED: 'IGNORED', // site candidate discarded: not to be added to star catalogue + APPROVED: 'APPROVED', // site candidate is added to star catalogue + ERROR: 'ERROR', // site candidate is discovered +}; + +/** + * SiteCandidate - A class representing an SiteCandidate entity. + * Provides methods to access and manipulate SiteCandidate-specific data. + * + * @class SiteCandidate + * @extends BaseModel + */ +class SiteCandidate extends BaseModel { + // add your custom methods or overrides here +} + +export default SiteCandidate; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.schema.js b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.schema.js new file mode 100755 index 00000000..5557f16a --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-candidate/site-candidate.schema.js @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isObject, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import { validate as uuidValidate } from 'uuid'; + +import SchemaBuilder from '../base/schema.builder.js'; +import SiteCandidate, { SITE_CANDIDATE_SOURCES, SITE_CANDIDATE_STATUS } from './site-candidate.model.js'; +import SiteCandidateCollection from './site-candidate.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(SiteCandidate, SiteCandidateCollection) + .addReference('belongs_to', 'Site') + .addAttribute('siteId', { + type: 'string', + validate: (value) => !value || uuidValidate(value), + }) + .addAttribute('baseURL', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('hlxConfig', { + type: 'any', + required: true, + default: {}, + validate: (value) => isObject(value), + }) + .addAttribute('source', { + type: Object.values(SITE_CANDIDATE_SOURCES), + required: true, + }) + .addAttribute('status', { + type: Object.values(SITE_CANDIDATE_STATUS), + required: true, + }) + .addAttribute('updatedBy', { + type: 'string', + }) + .addAllIndexWithComposite('baseURL'); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.d.ts new file mode 100644 index 00000000..59ba8b45 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.d.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel } from '../index'; + +export interface SiteTopPage extends BaseModel { + getGeo(): string; + getImportedAt(): number; + getSiteId(): string; + getSource(): string; + getTopKeyword(): string; + getTraffic(): number; + getUrl(): string; + setGeo(geo: string): SiteTopPage; + setImportedAt(importedAt: number): SiteTopPage; + setSiteId(siteId: string): SiteTopPage; + setSource(source: string): SiteTopPage; + setTopKeyword(topKeyword: string): SiteTopPage; + setTraffic(traffic: number): SiteTopPage; + setUrl(url: string): SiteTopPage; +} + +export interface SiteTopPageCollection extends BaseCollection { + allBySiteId(siteId: string): Promise; + allBySiteIdAndSourceAndGeo(siteId: string, source: string, geo: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.js b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.js new file mode 100644 index 00000000..895684b0 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import SiteTopPage from './site-top-page.model.js'; +import SiteTopPageCollection from './site-top-page.collection.js'; + +export { + SiteTopPage, + SiteTopPageCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.collection.js b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.collection.js new file mode 100644 index 00000000..e4cc5baf --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.collection.js @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { hasText } from '@adobe/spacecat-shared-utils'; + +import BaseCollection from '../base/base.collection.js'; + +/** + * SiteTopPageCollection - A collection class responsible for managing SiteTopPage entities. + * Extends the BaseCollection to provide specific methods for interacting with SiteTopPage records. + * + * @class SiteTopPageCollection + * @extends BaseCollection + */ +class SiteTopPageCollection extends BaseCollection { + async removeForSiteId(siteId, source, geo) { + if (!hasText(siteId)) { + throw new Error('SiteId is required'); + } + + let topPagesToRemove; + + if (hasText(source) && hasText(geo)) { + topPagesToRemove = await this.allBySiteIdAndSourceAndGeo(siteId, source, geo); + } else { + topPagesToRemove = await this.allBySiteId(siteId); + } + + const topPageIdsToRemove = topPagesToRemove.map((topPage) => topPage.getId()); + + await this.removeByIds(topPageIdsToRemove); + } +} + +export default SiteTopPageCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.model.js b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.model.js new file mode 100755 index 00000000..c33d044d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.model.js @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +export const DEFAULT_GEO = 'global'; + +/** + * SiteTopPage - A class representing an SiteTopPage entity. + * Provides methods to access and manipulate SiteTopPage-specific data. + * + * @class SiteTopPage + * @extends BaseModel + */ +class SiteTopPage extends BaseModel { + // add your custom methods or overrides here +} + +export default SiteTopPage; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.schema.js b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.schema.js new file mode 100644 index 00000000..d73a6032 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site-top-page/site-top-page.schema.js @@ -0,0 +1,65 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isInteger, isIsoDate, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import { validate as uuidValidate } from 'uuid'; + +import SchemaBuilder from '../base/schema.builder.js'; +import SiteTopPage, { DEFAULT_GEO } from './site-top-page.model.js'; +import SiteTopPageCollection from './site-top-page.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(SiteTopPage, SiteTopPageCollection) + .addReference('belongs_to', 'Site', ['source', 'geo', 'traffic']) + .addAttribute('siteId', { + type: 'string', + required: true, + validate: (value) => uuidValidate(value), + }) + .addAttribute('url', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('traffic', { + type: 'number', + required: true, + validate: (value) => isInteger(value), + }) + .addAttribute('source', { + type: 'string', + required: true, + }) + .addAttribute('topKeyword', { + type: 'string', + }) + .addAttribute('geo', { + type: 'string', + required: false, + default: DEFAULT_GEO, + }) + .addAttribute('importedAt', { + type: 'string', + required: true, + default: () => new Date().toISOString(), + validate: (value) => isIsoDate(value), + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/site/index.d.ts new file mode 100644 index 00000000..1bb0ae2b --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/index.d.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { + Audit, BaseCollection, BaseModel, Organization, +} from '../index'; + +export interface Site extends BaseModel { + getAudits(): Promise; + getBaseURL(): string; + getConfig(): object; + getDeliveryType(): string; + getFulfillableItems(): object; + getGitHubURL(): string; + getHlxConfig(): object; + getIsLive(): boolean; + getIsLiveToggledAt(): string; + getOrganization(): Promise; + getOrganizationId(): string; + setConfig(config: object): Site; + setDeliveryType(deliveryType: string): Site; + setFulfillableItems(fulfillableItems: object): Site; + setGitHubURL(gitHubURL: string): Site; + setHlxConfig(hlxConfig: object): Site; + setIsLive(isLive: boolean): Site; + setOrganizationId(organizationId: string): Site; + toggleLive(): Site; +} + +export interface SiteCollection extends BaseCollection { + findByBaseURL(siteId: string): Promise; + allByDeliveryType(siteId: string): Promise; + allByOrganizationId(siteId: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/index.js b/packages/spacecat-shared-data-access/src/v2/models/site/index.js new file mode 100644 index 00000000..d0966dbd --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/index.js @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// eslint-disable-next-line import/no-cycle +import Site from './site.model.js'; +import SiteCollection from './site.collection.js'; + +export { + Site, + SiteCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/site.collection.js b/packages/spacecat-shared-data-access/src/v2/models/site/site.collection.js new file mode 100755 index 00000000..6169fa0a --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/site.collection.js @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * SiteCollection - A collection class responsible for managing Site entities. + * Extends the BaseCollection to provide specific methods for interacting with Site records. + * + * @class SiteCollection + * @extends BaseCollection + */ +class SiteCollection extends BaseCollection { + async allSitesToAudit() { + return (await this.all({ attributes: ['siteId'] })).map((site) => site.getId()); + } +} + +export default SiteCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/site.model.js b/packages/spacecat-shared-data-access/src/v2/models/site/site.model.js new file mode 100644 index 00000000..e600d47e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/site.model.js @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Config } from '../../../models/site/config.js'; +import BaseModel from '../base/base.model.js'; + +export const DELIVERY_TYPES = { + AEM_CS: 'aem_cs', + AEM_EDGE: 'aem_edge', + OTHER: 'other', +}; + +export const DEFAULT_DELIVERY_TYPE = DELIVERY_TYPES.AEM_EDGE; + +/** + * A class representing a Site entity. Provides methods to access and manipulate Site-specific data. + * @class Site + * @extends BaseModel + */ +class Site extends BaseModel { + getConfig() { + return Config(this.record.config); + } + + async getLatestAuditByType(auditType) { + const collection = this.entityRegistry.getCollection('AuditCollection'); + + return collection.findByIndexKeys({ siteId: this.getId(), auditType }); + } + + async toggleLive() { + const newIsLive = !this.getIsLive(); + this.setIsLive(newIsLive); + return this; + } +} + +export default Site; diff --git a/packages/spacecat-shared-data-access/src/v2/models/site/site.schema.js b/packages/spacecat-shared-data-access/src/v2/models/site/site.schema.js new file mode 100755 index 00000000..e757db73 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/site/site.schema.js @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { + isIsoDate, + isNonEmptyObject, + isObject, + isValidUrl, +} from '@adobe/spacecat-shared-utils'; + +import { Config, DEFAULT_CONFIG, validateConfiguration } from '../../../models/site/config.js'; +import SchemaBuilder from '../base/schema.builder.js'; + +import Site, { + DEFAULT_DELIVERY_TYPE, + DELIVERY_TYPES, +} from './site.model.js'; +import SiteCollection from './site.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(Site, SiteCollection) + // this will add an attribute 'organizationId' as well as an index 'byOrganizationId' + .addReference('belongs_to', 'Organization') + // has_many references do not add attributes or indexes + .addReference('has_many', 'Audits') + .addReference('has_many', 'Experiments') + .addReference('has_many', 'KeyEvents') + .addReference('has_many', 'Opportunities') + .addReference('has_many', 'SiteCandidates') + .addReference('has_many', 'SiteTopPages') + .addAttribute('baseURL', { + type: 'string', + required: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('config', { + type: 'any', + required: true, + default: DEFAULT_CONFIG, + validate: (value) => isNonEmptyObject(validateConfiguration(value)), + get: (value) => Config(value), + }) + .addAttribute('deliveryType', { + type: Object.values(DELIVERY_TYPES), + default: DEFAULT_DELIVERY_TYPE, + required: true, + }) + .addAttribute('gitHubURL', { + type: 'string', + validate: (value) => !value || isValidUrl(value), + }) + .addAttribute('hlxConfig', { + type: 'any', + default: {}, + validate: (value) => isObject(value), + }) + .addAttribute('isLive', { + type: 'boolean', + required: true, + default: false, + }) + .addAttribute('isLiveToggledAt', { + type: 'string', + watch: ['isLive'], + set: () => new Date().toISOString(), + validate: (value) => !value || isIsoDate(value), + }) + .addAllIndexWithComposite('baseURL') + .addIndex( + 'byDeliveryType', + { composite: ['deliveryType'] }, + { composite: ['updatedAt'] }, + ); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js deleted file mode 100644 index c667ae03..00000000 --- a/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { hasText } from '@adobe/spacecat-shared-utils'; - -import BaseCollection from './base.collection.js'; -import Suggestion from './suggestion.model.js'; - -/** - * SuggestionCollection - A collection class responsible for managing Suggestion entities. - * Extends the BaseCollection to provide specific methods for interacting with Suggestion records. - * - * @class SuggestionCollection - * @extends BaseCollection - */ -class SuggestionCollection extends BaseCollection { - /** - * Constructs an instance of SuggestionCollection. Tells the base class which model to use. - * @constructor - * @param {Object} service - The ElectroDB service instance used to manage Suggestion entities. - * @param {Object} modelFactory - A factory for creating model instances. - * @param {Object} log - A logger for capturing logging information. - */ - constructor(service, modelFactory, log) { - super(service, modelFactory, Suggestion, log); - } - - /** - * Retrieves all Suggestion entities by their associated Opportunity ID. - * @async - * @param {string} opportunityId - The unique identifier of the associated Opportunity. - * @returns {Promise} - A promise that resolves to an array of Suggestion - * instances related to the given Opportunity ID. - * @throws {Error} - Throws an error if the opportunityId is not provided or if the query fails. - */ - async allByOpportunityId(opportunityId) { - if (!hasText(opportunityId)) { - throw new Error('OpportunityId is required'); - } - return this.findByIndexKeys({ opportunityId }); - } - - /** - * Retrieves all Suggestion entities by their associated Opportunity ID and status. - * @param {string} opportunityId - The unique identifier of the associated Opportunity. - * @param {string} status - The status of the Suggestion entities - * @return {Promise} - A promise that resolves to an array of - * Suggestion instances. - * @throws {Error} - Throws an error if the opportunityId or status is not provided. - */ - async allByOpportunityIdAndStatus(opportunityId, status) { - if (!hasText(opportunityId)) { - throw new Error('OpportunityId is required'); - } - - if (!hasText(status)) { - throw new Error('Status is required'); - } - - return this.findByIndexKeys({ opportunityId, status }); - } - - /** - * Updates the status of multiple given suggestions. The given status must conform - * to the status enum defined in the Suggestion schema. - * Saves the updated suggestions to the database automatically. - * You don't need to call save() on the suggestions after calling this method. - * @async - * @param {Suggestion[]} suggestions - An array of Suggestion instances to update. - * @param {string} status - The new status to set for the suggestions. - * @return {Promise<*>} - A promise that resolves to the updated suggestions. - * @throws {Error} - Throws an error if the suggestions are not provided - * or if the status is invalid. - */ - async bulkUpdateStatus(suggestions, status) { - if (!Array.isArray(suggestions)) { - throw new Error('Suggestions must be an array'); - } - - const validStatuses = this._getEnumValues('status'); - if (!validStatuses?.includes(status)) { - throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`); - } - - suggestions.forEach((suggestion) => { - suggestion.setStatus(status); - }); - - await this._saveMany(suggestions); - - return suggestions; - } -} - -export default SuggestionCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.d.ts new file mode 100644 index 00000000..63dea9f5 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.d.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel, Opportunity } from '../index'; + +export interface Suggestion extends BaseModel { + getData(): object; + getKpiDeltas(): object; + getOpportunity(): Promise; + getOpportunityId(): string; + getRank(): number; + getStatus(): string; + getType(): string; + setData(data: object): Suggestion; + setKpiDeltas(kpiDeltas: object): Suggestion; + setOpportunityId(opportunityId: string): Suggestion; + setRank(rank: number): Suggestion; + setStatus(status: string): Suggestion; +} + +export interface SuggestionCollection extends BaseCollection { + allByOpportunityId(opportunityId: string): Promise; + allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; + bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.js new file mode 100644 index 00000000..1cf61afc --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import Suggestion from './suggestion.model.js'; +import SuggestionCollection from './suggestion.collection.js'; + +export { + Suggestion, + SuggestionCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.collection.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.collection.js new file mode 100644 index 00000000..6b29d00e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.collection.js @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; +import { STATUSES } from './suggestion.model.js'; + +/** + * SuggestionCollection - A collection class responsible for managing Suggestion entities. + * Extends the BaseCollection to provide specific methods for interacting with Suggestion records. + * + * @class SuggestionCollection + * @extends BaseCollection + */ +class SuggestionCollection extends BaseCollection { + /** + * Updates the status of multiple given suggestions. The given status must conform + * to the status enum defined in the Suggestion schema. + * Saves the updated suggestions to the database automatically. + * You don't need to call save() on the suggestions after calling this method. + * @async + * @param {Suggestion[]} suggestions - An array of Suggestion instances to update. + * @param {string} status - The new status to set for the suggestions. + * @return {Promise<*>} - A promise that resolves to the updated suggestions. + * @throws {Error} - Throws an error if the suggestions are not provided + * or if the status is invalid. + */ + async bulkUpdateStatus(suggestions, status) { + if (!Array.isArray(suggestions)) { + throw new Error('Suggestions must be an array'); + } + + if (!Object.values(STATUSES).includes(status)) { + throw new Error(`Invalid status: ${status}. Must be one of: ${Object.values(STATUSES).join(', ')}`); + } + + suggestions.forEach((suggestion) => { + suggestion.setStatus(status); + }); + + await this._saveMany(suggestions); + + return suggestions; + } +} + +export default SuggestionCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion.model.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.model.js similarity index 73% rename from packages/spacecat-shared-data-access/src/v2/models/suggestion.model.js rename to packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.model.js index c06d1ef2..fcab57ff 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/suggestion.model.js +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.model.js @@ -10,7 +10,22 @@ * governing permissions and limitations under the License. */ -import BaseModel from './base.model.js'; +import BaseModel from '../base/base.model.js'; + +export const STATUSES = { + NEW: 'NEW', + APPROVED: 'APPROVED', + SKIPPED: 'SKIPPED', + FIXED: 'FIXED', + ERROR: 'ERROR', +}; + +export const TYPES = { + CODE_CHANGE: 'CODE_CHANGE', + CONTENT_UPDATE: 'CONTENT_UPDATE', + REDIRECT_UPDATE: 'REDIRECT_UPDATE', + METADATA_UPDATE: 'METADATA_UPDATE', +}; /** * Suggestion - A class representing a Suggestion entity. diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.schema.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.schema.js new file mode 100644 index 00000000..437ee34d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion/suggestion.schema.js @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Suggestion, { STATUSES, TYPES } from './suggestion.model.js'; +import SuggestionCollection from './suggestion.collection.js'; + +/* +Schema Doc: https://electrodb.dev/en/modeling/schema/ +Attribute Doc: https://electrodb.dev/en/modeling/attributes/ +Indexes Doc: https://electrodb.dev/en/modeling/indexes/ + */ + +const schema = new SchemaBuilder(Suggestion, SuggestionCollection) + .addReference('belongs_to', 'Opportunity', ['status', 'rank']) + .addAttribute('type', { + type: Object.values(TYPES), + required: true, + readOnly: true, + }) + .addAttribute('rank', { + type: 'number', + required: true, + }) + .addAttribute('data', { + type: 'any', + required: true, + validate: (value) => isNonEmptyObject(value), + }) + .addAttribute('kpiDeltas', { + type: 'any', + validate: (value) => !value || isNonEmptyObject(value), + }) + .addAttribute('status', { + type: Object.values(STATUSES), + required: true, + default: STATUSES.NEW, + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/v2/readme.md b/packages/spacecat-shared-data-access/src/v2/readme.md old mode 100644 new mode 100755 index 3c4de64a..e9cb2c05 --- a/packages/spacecat-shared-data-access/src/v2/readme.md +++ b/packages/spacecat-shared-data-access/src/v2/readme.md @@ -1,272 +1,217 @@ -# ElectroDB Model Framework - -This repository contains a model framework built using the ElectroDB ORM, designed to manage website improvements in a scalable manner. The system consists of several entities, including Opportunities and Suggestions, which represent potential areas of improvement and the actions to resolve them. - -## Table of Contents - -1. [Architecture Overview](#architecture-overview) -2. [Entities and Relationships](#entities-and-relationships) -3. [Getting Started](#getting-started) -4. [Adding a New ElectroDB-Based Entity](#adding-a-new-electrodb-based-entity) - - [Step 1: Define the Entity Schema](#step-1-define-the-entity-schema) - - [Step 2: Add a Model Class](#step-2-add-a-model-class) - - [Step 3: Add a Collection Class](#step-3-add-a-collection-class) - - [Step 4: Integrate the Entity into Model Factory](#step-4-integrate-the-entity-into-model-factory) - - [Step 5: Write Unit and Integration Tests](#step-5-write-unit-and-integration-tests) - - [Step 6: Create JSDoc and Update Documentation](#step-6-create-jsdoc-and-update-documentation) - - [Step 7: Run Tests and Verify](#step-7-run-tests-and-verify) - -## Architecture Overview - -The architecture follows a collection-management pattern with ElectroDB, enabling efficient handling of DynamoDB entities. The architecture is organized into the following layers: - -1. **Data Layer**: Uses DynamoDB with ElectroDB to manage schema definitions and data interactions. -2. **Model Layer**: The `BaseModel` provides methods like `save`, `remove`, and manages associations. Entity classes such as `Opportunity` and `Suggestion` extend `BaseModel` for specific features. -3. **Collection Layer**: The `BaseCollection` handles CRUD operations for entities. Specialized collections, like `OpportunityCollection` and `SuggestionCollection`, extend `BaseCollection` with tailored methods for specific entities. -4. **Factory Layer**: The `ModelFactory` centralizes instantiation of models and collections, providing a unified interface for different entity types. - -### Architectural Diagram - -```plaintext -+--------------------+ -| Data Layer | -|--------------------| -| DynamoDB + ElectroDB ORM | -+--------------------+ - ↓ -+--------------------+ -| Collection Layer | -|--------------------| -| BaseCollection, | -| OpportunityCollection, | -| SuggestionCollection | -+--------------------+ - ↓ -+--------------------+ -| Model Layer | -|--------------------| -| BaseModel, | -| Opportunity, | -| Suggestion | -+--------------------+ - ↓ -+--------------------+ -| Factory Layer | -|--------------------| -| ModelFactory | -+--------------------+ +# ElectroDB Entity Framework + +## Overview + +This entity framework streamlines the definition, querying, and manipulation of domain entities in a DynamoDB-based application. Built atop [ElectroDB](https://electrodb.dev/), it provides a consistent layer for schema definition, indexing, and robust CRUD operations, while adding conveniences like automatic indexing methods and reference handling. + +By adhering to this framework’s conventions, you can introduce and manage new entities with minimal boilerplate and complexity. + +## Core Concepts + +### Entities +An *entity* represents a domain concept (e.g., `User`, `Organization`, `Order`) persisted in the database. Each entity is defined by a schema, specifying attributes, indexes, and references to other entities. The schema integrates with ElectroDB, ensuring a uniform approach to modeling data. + +### Models +A *Model* is a class representing a single instance of an entity. It provides: + +- Attribute getters and setters generated based on the schema. +- Methods for persisting changes (`save()`), and removing entities (`remove()`). +- Methods to fetch referenced entities (via `belongs_to`, `has_one`, `has_many` references). + +Models extend `BaseModel`, which handles most of the common logic. + +### Collections +A *Collection* operates on sets of entities. While `Model` focuses on individual records, `Collection` is for batch and query-level operations: + +- Query methods like `findById()`, `all()`, and index-derived methods. +- Batch creation and update methods (`createMany`, `_saveMany`). +- Automatic generation of `allBy...` and `findBy...` convenience methods based on defined indexes. + +Collections extend `BaseCollection`, which generates query methods at runtime based on your schema definitions. + +### Schema Builder +The `SchemaBuilder` is a fluent API to define an entity’s schema: + +- **Attributes:** Configure entity fields and their validation. +- **Indexes:** Specify primary and secondary indexes for common queries. +- **References:** Define entity relationships (e.g., `User` belongs to `Organization`). + +The `SchemaBuilder` enforces naming conventions and sets defaults, reducing repetitive configuration. + +**Note on Indexes:** Add indexes thoughtfully. Every extra index adds cost and complexity. Only create indexes for well-understood, frequently-needed query patterns. + +### Entity Registry +The `EntityRegistry` aggregates all entities, their schemas, and their collections. It ensures consistent lookup and retrieval of any registered entity’s collection. When you add a new entity, you must register it with the `EntityRegistry` so the rest of the application can discover it. + +## Default Attributes and Indexes + +When you create a schema with `SchemaBuilder`, the following attributes are automatically defined: + +1. **ID (Primary Key):** A UUID-based primary key (`${entityName}Id`), ensuring unique identification. +2. **createdAt:** A timestamp (ISO string) set at entity creation. +3. **updatedAt:** A timestamp (ISO string) updated on each modification. + +A primary index is also set up, keyed by the `${entityName}Id` attribute, guaranteeing a straightforward way to retrieve entities by their unique ID. + +## Auto-Generated Methods + +### `BaseCollection` + +`BaseCollection` automatically generates `allBy...` and `findBy...` methods derived from your defined indexes. For example, if your schema defines an index composed of `opportunityId`, `status`, and `createdAt`, `BaseCollection` will generate: + +- `allByOpportunityId(opportunityId, options?)` +- `findByOpportunityId(opportunityId, options?)` +- `allByOpportunityIdAndStatus(opportunityId, status, options?)` +- `findByOpportunityIdAndStatus(opportunityId, status, options?)` +- `allByOpportunityIdAndStatusAndCreatedAt(opportunityId, status, createdAt, options?)` +- `findByOpportunityIdAndStatusAndCreatedAt(opportunityId, status, createdAt, options?)` + +**allBy...** methods return arrays of matching entities, while **findBy...** methods return a single (or the first matching) entity. Both can accept an optional `options` object for filtering, ordering, attribute selection, and pagination. + +**Example:** +```js +const Suggestion = dataAccess.Suggestion; + +// Retrieve all suggestions by `opportunityId` +const results = await Suggestion.allByOpportunityId('op-12345'); + +// Retrieve a single suggestion by `opportunityId` and `status` +const single = await Suggestion.findByOpportunityIdAndStatus('op-12345', 'OPEN'); ``` -## Entities and Relationships - -- **Opportunity**: Represents a specific issue identified on a website. It includes attributes like `title`, `description`, `siteId`, and `status`. -- **Suggestion**: Represents a proposed fix for an Opportunity. Attributes include `opportunityId`, `type`, `status`, and `rank`. -- **Relationships**: Opportunities have many Suggestions. This relationship is implemented through `OpportunityCollection` and `SuggestionCollection`, which interact via ElectroDB-managed DynamoDB relationships. - -## Getting Started - -1. **Install Dependencies** - ```bash - npm install - ``` - -2. **Setup DynamoDB** - - Ensure AWS credentials are configured and a DynamoDB table is set up. - - Configure the DynamoDB table name and related settings in `index.js`. - -3. **Usage Example** - ```javascript - import { createDataAccess } from './index.js'; - - const config = { tableNameData: 'YOUR_TABLE_NAME' }; - const log = console; - const dao = createDataAccess(config, log); - - // Create a new Opportunity - const opportunityData = { title: 'Broken Links', siteId: 'site123', type: 'broken-backlinks' }; - const newOpportunity = await dao.Opportunity.create(opportunityData); - console.log('New Opportunity Created:', newOpportunity); - ``` - -4. **Extending Functionality** - - Add new models by extending `BaseModel` and new collections by extending `BaseCollection`. - - Register new models in the `ModelFactory` for unified access. - -## Adding a New ElectroDB-Based Entity - -This guide provides a step-by-step overview for adding a new ElectroDB-based entity to the application. - -### Step 1: Define the Entity Schema - -1. **Create Entity Schema File**: Define the entity schema in a new file (e.g., `myNewEntity.schema.js`) within the `/schemas/` directory. - - ```javascript - export const MyNewEntitySchema = { - model: { - entity: 'MyNewEntity', - service: 'MyService', - version: '1', - }, - attributes: { - myNewEntityId: { - type: 'string', - required: true, - }, - name: { - type: 'string', - required: true, - }, - status: { - type: 'string', - enum: ['NEW', 'IN_PROGRESS', 'COMPLETED'], - required: true, - }, - createdAt: { - type: 'string', - required: true, - default: () => new Date().toISOString(), - }, - }, - indexes: { - myNewEntityIndex: { - pk: { - field: 'pk', - facets: ['myNewEntityId'], - }, - sk: { - field: 'sk', - facets: ['status'], - }, - }, - }, - references: { - belongs_to: [ - { type: 'belongs_to', target: 'Opportunity' }, - ], - }, - }; - ``` - -2. **Declare References**: Use the `references` field to define relationships between entities. This sets up associations for easy fetching and managing of related entities, allowing for automatic generation of reference getter methods. - -### Step 2: Add a Model Class - -1. **Create the Model Class**: In the `/models/` directory, add `myNewEntity.model.js`. - - ```javascript - import BaseModel from './base.model.js'; - - class MyNewEntity extends BaseModel { - constructor(electroService, modelFactory, record, log) { - super(electroService, modelFactory, record, log); - } - } - - export default MyNewEntity; - ``` - - Note: By using `BaseModel`, entity classes can remain empty unless there is a need to: - - Override automatically generated getters or setters for specific attributes. - - Add custom methods specific to the entity. - -### Automatic Getter and Setter Methods - -The `BaseModel` automatically generates getter and setter methods for each attribute defined in the entity schema: - -- **Utility Methods**: `BaseModel` provides `getId()`, `getCreatedAt()`, and `getUpdatedAt()` methods out of the box for accessing common entity information like the unique identifier, creation timestamp, and last update timestamp. - -- **Getters**: Follow the convention `get()` to access attribute values. -- **Setters**: Follow the convention `set(value)` to modify entity values, while handling patching. - -Example: - -- If an attribute is named `name`, `BaseModel` will automatically generate: - - `getName()`: Retrieve the value of `name`. - - `setName(value)`: Update the value of `name`. - -This reduces boilerplate and ensures consistency. - -### Automatic Reference Getter Methods - -If references are defined in the schema (e.g., `belongs_to`, `has_many`), `BaseModel` generates reference getter methods: - -- **References Getter Naming**: - - Methods are named `get()`, where `` corresponds to the target specified in the `references` field. - - Example: - ```javascript - references: { - belongs_to: [ - { type: 'belongs_to', target: 'Opportunity' }, - ], - }, - ``` - This results in a `getOpportunity()` method for accessing the related `Opportunity` entity. - -### Step 3: Add a Collection Class - -1. **Create the Collection Class**: Add `myNewEntity.collection.js` in the `/collections/` directory. - - ```javascript - import BaseCollection from './base.collection.js'; - import MyNewEntity from '../models/myNewEntity.model.js'; - - class MyNewEntityCollection extends BaseCollection { - constructor(service, modelFactory, log) { - super(service, modelFactory, MyNewEntity, log); - } - - async allByStatus(status) { - return this.findByIndexKeys({ status }); - } - } - - export default MyNewEntityCollection; - ``` +### `BaseModel` -### Step 4: Integrate the Entity into Model Factory +`BaseModel` provides methods for CRUD operations and reference handling: -1. **Update the Model Factory**: Open `model.factory.js` and add the new entity and collection to the `initialize` method. +- `save()`: Persists changes to the entity. +- `remove()`: Deletes the entity from the database. +- `get...()`: Getters for entity attributes. +- `set...()`: Setters for entity attributes. - ```javascript - import MyNewEntityCollection from './collections/myNewEntity.collection.js'; +Additionally, `BaseModel` generates methods to fetch referenced entities. +For example, if `User` belongs to `Organization`, `BaseModel` will create: - class ModelFactory { - initialize() { - const myNewEntityCollection = new MyNewEntityCollection( - this.service, - this, - this.logger, - ); +- `getOrganization()`: Fetch the referenced `Organization` entity. +- `getOrganizationId()`: Retrieve the `Organization` ID. +- `setOrganizationId(organizationId)`: Update the `Organization` reference. - this.models.set(MyNewEntityCollection.name, myNewEntityCollection); - } - } - ``` +Conversely, the `Organization` entity will have: -### Step 5: Write Unit and Integration Tests +- `getUsers()`: Fetch all `User` entities referencing this `Organization`. +- And with the `User`-Schema's `belongs_to` reciprocal reference expressing filterable sort keys, e.g. "email", "location": + - `getUsersByEmail(email)`: Fetch all `User` entities referencing this `Organization` with a specific email." + - `getUsersByEmailAndLocation(email, location)`: Fetch all `User` entities referencing this `Organization` with a specific email and location. -1. **Create Unit Tests**: Add a file named `myNewEntity.model.test.js` in `/tests/unit/models/` to test all getters, setters, and interactions. - - Use Mocha, Chai, and Sinon for testing. +**Example:** +```js +const user = await User.findById('usr-abc123'); -2. **Create Collection Tests**: Add `myNewEntity.collection.test.js` to `/tests/unit/collections/`. - - Test methods interacting with ElectroDB, like `allByStatus`. +// Work with attributes +console.log(user.getEmail()); // e.g. "john@example.com" +user.setName('John Smith'); +await user.save(); -3. **Add Integration Tests**: Create an integration test file named `myNewEntity.integration.test.js` in `/tests/integration/` to test the full lifecycle of the entity. +// Fetch referenced entity +const org = await user.getOrganization(); +console.log(org.getName()); +``` -### Step 6: Create JSDoc and Update Documentation +## Step-by-Step: Adding a New Entity -1. **Generate JSDoc for Entity and Collection**: Add JSDoc comments for each function to describe the API. -2. **Update Type Definitions**: Modify `index.d.ts` to include new interfaces and types for the entity. +Follow these steps to introduce a new entity into the framework. -### Step 7: Run Tests and Verify +### 1. Define the Schema +Create `user.schema.js`: + +```js +import SchemaBuilder from '../base/schema.builder.js'; +import User from './user.model.js'; +import UserCollection from './user.collection.js'; + +const userSchema = new SchemaBuilder(User, UserCollection) + .addAttribute('email', { + type: 'string', + required: true, + validate: (value) => value.includes('@'), + }) + .addAttribute('name', { type: 'string', required: true }) + .addAllIndexWithComposite('email') + .addReference('belongs_to', 'Organization') // Adds organizationId and byOrganizationId index + .build(); + +export default userSchema; +``` + +### 2. Implement the Model +Create `user.model.js`: + +```js +import BaseModel from '../base/base.model.js'; + +class UserModel extends BaseModel { + // Additional domain logic methods can be added here if needed. +} + +export default UserModel; +``` + +### 3. Implement the Collection +Create `user.collection.js`: + +```js +import BaseCollection from '../base/base.collection.js'; +import UserModel from './user.model.js'; +import userSchema from './user.schema.js'; + +class UserCollection extends BaseCollection { + // Additional domain logic collection methods can be added here if needed. + async findByEmail(email) { + return this.findByIndexKeys({ email }); + } +} + +export default UserCollection; +``` + +### 4. Register the Entity +In `entity.registry.js` (or equivalent): + +```js +import UserSchema from '../user/user.schema.js'; +import UserCollection from '../user/user.collection.js'; + +EntityRegistry.registerEntity(UserSchema, UserCollection); +``` + +### 5. Update DynamoDB Configuration and `schema.json` + +After defining indexes in the schema, **manually add these indexes to your DynamoDB table configuration**. DynamoDB does not automatically create GSIs. You must: + +- Use the AWS Console, CLI, or CloudFormation/Terraform templates to define these GSIs. +- Update your `schema.json` or another documentation file to reflect the newly created indexes, so the team knows which indexes exist and what query patterns they support. + +### 6. Use the Entity +```js +const { User, Organization } = dataAccess; + +// Create a user +const newUser = await User.create({ email: 'john@example.com', name: 'John Doe' }); + +// Find user by ID +const user = await User.findById(newUser.getId()); + +// Get the user organization +const org = await user.getOrganization(); + +// ...or in reverse +const anOrg = await Organization.findById(user.getOrganizationId()); +const orgUsers = await anOrg.getUsers(); + +// Update user and save +user.setName('John X. Doe'); +await user.save(); +``` -1. **Run All Tests**: - ```bash - npm run test && npm run test:it - ``` +## Consideration for Indexes -2. **Run Linter**: Check for coding standard violations. - ```bash - npm run lint - ``` +Indexes cost money and complexity. Do not add indexes lightly. Determine which query patterns you truly need and only then introduce additional indexes. diff --git a/packages/spacecat-shared-data-access/src/v2/schema/opportunity.schema.js b/packages/spacecat-shared-data-access/src/v2/schema/opportunity.schema.js deleted file mode 100644 index 251ae83f..00000000 --- a/packages/spacecat-shared-data-access/src/v2/schema/opportunity.schema.js +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* c8 ignore start */ - -import { isNonEmptyObject, isValidUrl } from '@adobe/spacecat-shared-utils'; - -import { validate as uuidValidate, v4 as uuid } from 'uuid'; - -/* -Schema Doc: https://electrodb.dev/en/modeling/schema/ -Attribute Doc: https://electrodb.dev/en/modeling/attributes/ -Indexes Doc: https://electrodb.dev/en/modeling/indexes/ - */ - -const OpportunitySchema = { - model: { - entity: 'Opportunity', - version: '1', - service: 'SpaceCat', - }, - attributes: { - opportunityId: { - type: 'string', - required: true, - readOnly: true, - // https://electrodb.dev/en/modeling/attributes/#default - default: () => uuid(), - // https://electrodb.dev/en/modeling/attributes/#attribute-validation - validate: (value) => uuidValidate(value), - }, - siteId: { - type: 'string', - required: true, - validate: (value) => uuidValidate(value), - }, - auditId: { - type: 'string', - required: true, - validate: (value) => uuidValidate(value), - }, - runbook: { - type: 'string', - validate: (value) => !value || isValidUrl(value), - }, - type: { - type: 'string', - readOnly: true, - required: true, - }, - data: { - type: 'any', - required: false, - validate: (value) => !value || isNonEmptyObject(value), - }, - origin: { - type: ['ESS_OPS', 'AI', 'AUTOMATION'], - required: true, - }, - title: { - type: 'string', - required: true, - }, - description: { - type: 'string', - required: false, - }, - status: { - type: ['NEW', 'IN_PROGRESS', 'IGNORED', 'RESOLVED'], - required: true, - default: () => 'NEW', - }, - guidance: { - type: 'any', - required: false, - validate: (value) => !value || isNonEmptyObject(value), - }, - tags: { - type: 'set', - items: 'string', - required: false, - }, - createdAt: { - type: 'number', - readOnly: true, - required: true, - default: () => Date.now(), - set: () => Date.now(), - }, - updatedAt: { - type: 'number', - watch: '*', - required: true, - default: () => Date.now(), - set: () => Date.now(), - }, - // todo: add createdBy, updatedBy and auto-set from auth context - }, - indexes: { - primary: { // operates on the main table, no 'index' property - pk: { - field: 'pk', - composite: ['opportunityId'], - }, - sk: { - field: 'sk', - composite: [], - }, - }, - bySiteId: { - index: 'spacecat-data-opportunity-by-site', - pk: { - field: 'gsi1pk', - composite: ['siteId'], - }, - sk: { - field: 'gsi1sk', - composite: ['opportunityId'], - }, - }, - bySiteIdAndStatus: { - index: 'spacecat-data-opportunity-by-site-and-status', - pk: { - field: 'gsi2pk', - composite: ['siteId', 'status'], - }, - sk: { - field: 'gsi2sk', - composite: ['updatedAt'], - }, - }, - }, -}; - -/** - * References to other entities. This is not part of the standard ElectroDB schema, but is used - * to define relationships between entities in our data layer API. - * @type {{belongs_to: [{type: string, target: string}]}} - */ -OpportunitySchema.references = { - has_many: [ - { type: 'has_many', target: 'Suggestions' }, - ], - belongs_to: [ - { type: 'belongs_to', target: 'Site' }, - { type: 'belongs_to', target: 'Audit' }, - ], -}; - -export default OpportunitySchema; diff --git a/packages/spacecat-shared-data-access/src/v2/schema/suggestion.schema.js b/packages/spacecat-shared-data-access/src/v2/schema/suggestion.schema.js deleted file mode 100644 index 1f4df360..00000000 --- a/packages/spacecat-shared-data-access/src/v2/schema/suggestion.schema.js +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* c8 ignore start */ - -import { v4 as uuid, validate as uuidValidate } from 'uuid'; -import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; - -/* -Schema Doc: https://electrodb.dev/en/modeling/schema/ -Attribute Doc: https://electrodb.dev/en/modeling/attributes/ -Indexes Doc: https://electrodb.dev/en/modeling/indexes/ - */ - -const SuggestionSchema = { - model: { - entity: 'Suggestion', - version: '1', - service: 'SpaceCat', - }, - attributes: { - suggestionId: { - type: 'string', - required: true, - readOnly: true, - // https://electrodb.dev/en/modeling/attributes/#default - default: () => uuid(), - // https://electrodb.dev/en/modeling/attributes/#attribute-validation - validate: (value) => uuidValidate(value), - }, - opportunityId: { - type: 'string', - required: true, - validate: (value) => uuidValidate(value), - }, - type: { - type: ['CODE_CHANGE', 'CONTENT_UPDATE', 'REDIRECT_UPDATE', 'METADATA_UPDATE'], - required: true, - readOnly: true, - }, - rank: { - type: 'number', - required: true, - }, - data: { - type: 'any', - required: true, - validate: (value) => isNonEmptyObject(value), - }, - kpiDeltas: { - type: 'any', - required: false, - validate: (value) => !value || isNonEmptyObject(value), - }, - status: { - type: ['NEW', 'APPROVED', 'SKIPPED', 'FIXED', 'ERROR'], - required: true, - default: () => 'NEW', - }, - createdAt: { - type: 'number', - readOnly: true, - required: true, - default: () => Date.now(), - set: () => Date.now(), - }, - updatedAt: { - type: 'number', - watch: '*', - required: true, - default: () => Date.now(), - set: () => Date.now(), - }, - // todo: add createdBy, updatedBy and auto-set from auth context - }, - indexes: { - primary: { // operates on the main table, no 'index' property - pk: { - field: 'pk', - composite: ['suggestionId'], - }, - sk: { - field: 'sk', - composite: [], - }, - }, - byOpportunityId: { - index: 'spacecat-data-suggestion-by-opportunity', - pk: { - field: 'gsi1pk', - composite: ['opportunityId'], - }, - sk: { - field: 'gsi1sk', - composite: ['suggestionId'], - }, - }, - byOpportunityIdAndStatus: { - index: 'spacecat-data-suggestion-by-opportunity-and-status', - pk: { - field: 'gsi2pk', - composite: ['opportunityId'], - }, - sk: { - field: 'gsi2sk', - composite: ['status', 'rank'], - }, - }, - }, -}; - -/** - * References to other entities. This is not part of the standard ElectroDB schema, but is used - * to define relationships between entities in our data layer API. - * @type {{belongs_to: [{type: string, target: string}]}} - */ -SuggestionSchema.references = { - belongs_to: [ - { type: 'belongs_to', target: 'Opportunity' }, - ], -}; - -export default SuggestionSchema; diff --git a/packages/spacecat-shared-data-access/src/v2/util/accessor.utils.js b/packages/spacecat-shared-data-access/src/v2/util/accessor.utils.js new file mode 100644 index 00000000..6c63856f --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/util/accessor.utils.js @@ -0,0 +1,158 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { hasText, isNonEmptyObject, isNumber } from '@adobe/spacecat-shared-utils'; + +import ValidationError from '../errors/validation.error.js'; + +function validateValue(context, keyName, value) { + const { type } = context.schema.getAttribute(keyName); + const validator = type === 'number' ? isNumber : hasText; + + if (!validator(value)) { + throw new ValidationError(`${keyName} is required`); + } +} + +function parseAccessorArgs(context, requiredKeyNames, args) { + const keys = {}; + for (let i = 0; i < requiredKeyNames.length; i += 1) { + const keyName = requiredKeyNames[i]; + const keyValue = args[i]; + + validateValue(context, keyName, keyValue); + + keys[keyName] = keyValue; + } + + let options = {}; + + if (args.length > requiredKeyNames.length) { + options = args[requiredKeyNames.length]; + } + + return { keys, options }; +} + +function validateConfig(config) { + if (!isNonEmptyObject(config)) { + throw new Error('Config is required'); + } + + const { + collection, context, name, requiredKeys, + } = config; + + if (!isNonEmptyObject(collection)) { + throw new Error('Collection is required'); + } + + if (!isNonEmptyObject(context)) { + throw new Error('Context is required'); + } + + if (!hasText(name)) { + throw new Error('Name is required'); + } + + if (!Array.isArray(requiredKeys)) { + throw new Error('Required keys must be an array'); + } +} + +/** + * Create an accessor for a collection. The accessor can be used to query the collection. + * @param {object} config - The accessor configuration. + * @param {boolean} [config.all=false] - Whether to return all items in the collection. + * @param {boolean} [config.byId=false] - Whether to return an item by ID. + * @param {object} config.collection - The collection to query. + * @param {object} config.context - The context to attach the accessor to. + * @param {object} [config.foreignKey] - The foreign key to use when querying by ID. + * @param {string} config.name - The name of the accessor. + * @param {string[]} [config.requiredKeys] - The required keys for the accessor. + * @throws {Error} - If the configuration is invalid. + * @returns {void} + */ +export function createAccessor(config) { /* eslint-disable no-underscore-dangle */ + validateConfig(config); + + const { + all = false, + byId = false, + collection, + context, + foreignKey, + name, + requiredKeys = [], + } = config; + if (!context._accessorCache) { + Object.defineProperty(context, '_accessorCache', { + enumerable: false, + configurable: true, + writable: true, + value: {}, + }); + } + + const foreignKeys = { + ...isNonEmptyObject(foreignKey) && { [foreignKey.name]: foreignKey.value }, + }; + + const accessor = async (...args) => { + const argsKey = args.length > 0 ? JSON.stringify(args) : '_'; + const cacheKey = `${name}:${argsKey}`; + + if (context._accessorCache[cacheKey] !== undefined) { + return context._accessorCache[cacheKey]; + } + + let result; + + if (byId) { + if (!hasText(foreignKey.value)) { + result = null; + } else { + result = collection.findById(foreignKey.value); + } + } else { + const { keys, options } = parseAccessorArgs(collection, requiredKeys, args); + const allKeys = { ...foreignKeys, ...keys }; + + result = all + ? collection.allByIndexKeys(allKeys, options) + : collection.findByIndexKeys(allKeys, options); + } + + result = await result; + context._accessorCache[cacheKey] = result; + + return result; + }; + + Object.defineProperty( + context, + name, + { + enumerable: false, + configurable: false, + writable: true, + value: accessor, + }, + ); +} + +export function createAccessors(configs, log) { + configs.forEach((config) => { + createAccessor(config); + log.info(`Created accessor ${config.name} for ${config.context.schema.getModelName()} to ${config.collection.schema.getModelName()}`); + }); +} diff --git a/packages/spacecat-shared-data-access/src/v2/util/guards.d.ts b/packages/spacecat-shared-data-access/src/v2/util/guards.d.ts old mode 100644 new mode 100755 index 807c1bba..a67ce031 --- a/packages/spacecat-shared-data-access/src/v2/util/guards.d.ts +++ b/packages/spacecat-shared-data-access/src/v2/util/guards.d.ts @@ -25,6 +25,13 @@ export function guardAny( nullable?: boolean, ): void; +export function guardBoolean( + propertyName: string, + value: never, + entityName: string, + nullable?: boolean, +): void; + export function guardEnum( propertyName: string, value: never, diff --git a/packages/spacecat-shared-data-access/src/v2/util/guards.js b/packages/spacecat-shared-data-access/src/v2/util/guards.js index 5fe29c46..7f679213 100644 --- a/packages/spacecat-shared-data-access/src/v2/util/guards.js +++ b/packages/spacecat-shared-data-access/src/v2/util/guards.js @@ -32,14 +32,16 @@ const checkNullable = (value, nullable) => nullable && (value === null || value */ const checkType = (value, type) => { switch (type) { + case 'any': + return isObject(value); + case 'boolean': + return typeof value === 'boolean'; + case 'map': + return isObject(value); case 'string': return typeof value === 'string'; case 'number': return typeof value === 'number'; - case 'boolean': - return typeof value === 'boolean'; - case 'object': - return isObject(value); default: throw new ValidationError(`Unsupported type: ${type}`); } @@ -59,6 +61,21 @@ export const guardAny = (propertyName, value, entityName, nullable = false) => { } }; +/** + * Validates that a given property is a boolean. + * @param {String} propertyName - Name of the property being validated. + * @param {any} value - The value to validate. + * @param {String} entityName - Name of the entity containing this property. + * @param {boolean} [nullable] - Whether the value is nullable. Defaults to false. + * @throws Will throw an error if the value is not a valid boolean. + */ +export const guardBoolean = (propertyName, value, entityName, nullable = false) => { + if (checkNullable(value, nullable)) return; + if (typeof value !== 'boolean') { + throw new ValidationError(`Validation failed in ${entityName}: ${propertyName} must be a boolean`); + } +}; + export const guardArray = (propertyName, value, entityName, type = 'string', nullable = false) => { if (checkNullable(value, nullable)) return; if (!Array.isArray(value)) { diff --git a/packages/spacecat-shared-data-access/src/v2/util/index.js b/packages/spacecat-shared-data-access/src/v2/util/index.js index 8827cb95..e44dfa15 100644 --- a/packages/spacecat-shared-data-access/src/v2/util/index.js +++ b/packages/spacecat-shared-data-access/src/v2/util/index.js @@ -13,6 +13,7 @@ export { guardAny, guardArray, + guardBoolean, guardEnum, guardId, guardMap, diff --git a/packages/spacecat-shared-data-access/src/v2/util/patcher.js b/packages/spacecat-shared-data-access/src/v2/util/patcher.js old mode 100644 new mode 100755 index 84411bcd..d8f6635a --- a/packages/spacecat-shared-data-access/src/v2/util/patcher.js +++ b/packages/spacecat-shared-data-access/src/v2/util/patcher.js @@ -16,6 +16,7 @@ import ValidationError from '../errors/validation.error.js'; import { guardAny, + guardBoolean, guardArray, guardEnum, guardId, @@ -24,6 +25,7 @@ import { guardSet, guardString, } from './index.js'; +import { isNonEmptyArray } from './util.js'; /** * Checks if a property is read-only and throws an error if it is. @@ -39,12 +41,24 @@ const checkReadOnly = (propertyName, attribute) => { }; class Patcher { - constructor(entity, record) { + /** + * Creates a new Patcher instance for an entity. + * @param {object} entity - The entity backing the record. + * @param {Schema} schema - The schema for the entity. + * @param {object} record - The record to patch. + */ + constructor(entity, schema, record) { this.entity = entity; - this.entityName = this.entity.model.name.toLowerCase(); - this.model = entity.model; - this.idName = `${this.model.name.toLowerCase()}Id`; this.record = record; + + this.entityName = schema.getEntityName(); + this.model = entity.model; + this.idName = schema.getIdName(); + + // holds the previous value of updated attributes + this.previous = {}; + + // holds the updates to the attributes this.updates = {}; this.patchRecord = null; @@ -61,24 +75,25 @@ class Patcher { } /** - * Gets the composite values for a given key from the entity schema. * Composite keys have to be provided to ElectroDB in order to update a record across - * multiple indexes. - * @param {Object} record - The record to get the composite values from. - * @param {string} key - The key to get the composite values for. - * @return {{}} - An object containing the composite values for the given key. + * multiple indexes. This method retrieves the composite values for the entity from + * the schema indexes and filters out any values that are being updated. + * @return {{}} - An object containing the composite values for the entity. * @private */ - #getCompositeValuesForKey(record, key) { + #getCompositeValues() { const { indexes } = this.model; const result = {}; const processComposite = (index, compositeType) => { const compositeArray = index[compositeType]?.facets; - if (Array.isArray(compositeArray) && compositeArray.includes(key)) { + if (isNonEmptyArray(compositeArray)) { compositeArray.forEach((compositeKey) => { - if (record[compositeKey] !== undefined) { - result[compositeKey] = record[compositeKey]; + if ( + !Object.keys(this.updates).includes(compositeKey) + && this.record[compositeKey] !== undefined + ) { + result[compositeKey] = this.record[compositeKey]; } }); } @@ -94,18 +109,25 @@ class Patcher { /** * Sets a property on the record and updates the patch record. - * @param {string} propertyName - The name of the property to set. + * @param {string} attribute - The attribute to set. * @param {any} value - The value to set for the property. * @private */ - #set(propertyName, value) { - const compositeValues = this.#getCompositeValuesForKey(this.record, propertyName); - this.patchRecord = this.#getPatchRecord().set({ - ...compositeValues, - [propertyName]: value, - }); - this.record[propertyName] = value; - this.updates[propertyName] = value; + #set(attribute, value) { + this.patchRecord = this.#getPatchRecord().set({ [attribute.name]: value }); + + const update = { + [attribute.name]: { + previous: this.record[attribute.name], + current: value, + }, + }; + + // update the record with the update value for later save + this.record[attribute.name] = value; + + // remember the update operation with the previous and current value + this.updates = { ...this.updates, ...update }; } /** @@ -145,6 +167,9 @@ class Patcher { case 'any': guardAny(propertyName, value, this.entityName, nullable); break; + case 'boolean': + guardBoolean(propertyName, value, this.entityName, nullable); + break; case 'enum': guardEnum(propertyName, value, attribute.enumArray, this.entityName, nullable); break; @@ -168,7 +193,7 @@ class Patcher { } } - this.#set(propertyName, value); + this.#set(attribute, value); } /** @@ -180,8 +205,12 @@ class Patcher { if (!this.hasUpdates()) { return; } - await this.#getPatchRecord().go(); - this.record.updatedAt = new Date().getTime(); + + const compositeValues = this.#getCompositeValues(); + await this.#getPatchRecord() + .composite(compositeValues) + .go(); + this.record.updatedAt = new Date().toISOString(); } getUpdates() { diff --git a/packages/spacecat-shared-data-access/src/v2/util/reference.js b/packages/spacecat-shared-data-access/src/v2/util/reference.js deleted file mode 100644 index c8c6ce24..00000000 --- a/packages/spacecat-shared-data-access/src/v2/util/reference.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import pluralize from 'pluralize'; - -const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1); -const entityNameToCollectionName = (entityName) => `${pluralize.singular(entityName)}Collection`; -const entityNameToIdName = (collectionName) => `${collectionName.charAt(0).toLowerCase() + collectionName.slice(1)}Id`; -const entityNameToReferenceMethodName = (target, type) => { - let baseName = target.charAt(0).toUpperCase() + target.slice(1); - baseName = type === 'has_many' - ? pluralize.plural(baseName) - : pluralize.singular(baseName); - - return `get${baseName}`; -}; - -const idNameToEntityName = (idName) => capitalize(pluralize.singular(idName.replace('Id', ''))); - -const keyNamesToIndexName = (keyNames) => { - const capitalizedKeyNames = keyNames.map((keyName) => capitalize(keyName)); - return `by${capitalizedKeyNames.join('And')}`; -}; - -export { - capitalize, - entityNameToCollectionName, - entityNameToIdName, - entityNameToReferenceMethodName, - idNameToEntityName, - keyNamesToIndexName, -}; diff --git a/packages/spacecat-shared-data-access/src/v2/util/util.js b/packages/spacecat-shared-data-access/src/v2/util/util.js new file mode 100644 index 00000000..04792a20 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/v2/util/util.js @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { hasText, isInteger } from '@adobe/spacecat-shared-utils'; +import pluralize from 'pluralize'; + +const capitalize = (str) => (hasText(str) ? str[0].toUpperCase() + str.slice(1) : ''); + +const classExtends = (clazz, base) => (typeof clazz === 'function' && clazz.prototype instanceof base); + +const decapitalize = (str) => (hasText(str) ? str[0].toLowerCase() + str.slice(1) : ''); + +const collectionNameToEntityName = (collectionName) => collectionName.replace('Collection', ''); + +const entityNameToCollectionName = (entityName) => `${capitalize(pluralize.singular(entityName))}Collection`; + +const entityNameToIdName = (entityName) => `${decapitalize(pluralize.singular(entityName))}Id`; + +const referenceToBaseMethodName = (reference) => { + const target = capitalize(reference.getTarget()); + const baseName = reference.getType() === 'has_many' + ? pluralize.plural(target) + : pluralize.singular(target); + + return `get${baseName}`; +}; + +const entityNameToAllPKValue = (entityName) => `ALL_${pluralize.plural(entityName.toUpperCase())}`; + +const idNameToEntityName = (idName) => capitalize(pluralize.singular(idName.replace('Id', ''))); + +const isPositiveInteger = (value) => isInteger(value) && value > 0; + +const keyNamesToIndexName = (keyNames) => `by${keyNames.map(capitalize).join('And')}`; + +const keyNamesToMethodName = (keyNames, prefix) => prefix + keyNames.map(capitalize).join('And'); + +const modelNameToEntityName = (modelName) => decapitalize(modelName); + +const sanitizeTimestamps = (data) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { createdAt, updatedAt, ...rest } = data; + return rest; +}; + +const sanitizeIdAndAuditFields = (entityName, data) => { + const idName = entityNameToIdName(entityName); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [idName]: _, ...rest } = data; + return sanitizeTimestamps(rest); +}; + +const incrementVersion = (version) => (isInteger(version) ? parseInt(version, 10) + 1 : 1); + +const isNonEmptyArray = (value) => Array.isArray(value) && value.length > 0; + +export { + capitalize, + classExtends, + collectionNameToEntityName, + decapitalize, + entityNameToAllPKValue, + entityNameToCollectionName, + entityNameToIdName, + idNameToEntityName, + incrementVersion, + isNonEmptyArray, + isPositiveInteger, + keyNamesToIndexName, + keyNamesToMethodName, + modelNameToEntityName, + referenceToBaseMethodName, + sanitizeIdAndAuditFields, + sanitizeTimestamps, +}; diff --git a/packages/spacecat-shared-data-access/test/fixtures/api-keys.fixtures.js b/packages/spacecat-shared-data-access/test/fixtures/api-keys.fixtures.js new file mode 100644 index 00000000..cb89acba --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/api-keys.fixtures.js @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const apiKeys = [ + { + name: 'Test API Key 1', + expiresAt: '2025-10-09T19:21:55.834Z', + hashedApiKey: 'some-key-1', + imsOrgId: 'org-1@AdobeOrg', + imsUserId: 'user-1', + scopes: [{ + name: 'imports.read', + }, + { + name: 'imports.write', + domains: ['https://example.com'], + }], + }, + { + name: 'Test API Key 2', + expiresAt: '2025-10-09T19:21:55.834Z', + hashedApiKey: 'some-key-2', + imsOrgId: 'org-2@AdobeOrg', + imsUserId: 'user-2', + scopes: [{ + name: 'imports.read', + }, + { + name: 'imports.write', + domains: ['https://example.com'], + }], + }, + { + name: 'Test API Key 3', + expiresAt: '2025-10-09T19:21:55.834Z', + hashedApiKey: 'some-key-2', + imsOrgId: 'org-1@AdobeOrg', + imsUserId: 'user-1', + scopes: [{ + name: 'imports.read', + }, + { + name: 'imports.write', + domains: ['https://example-3.com'], + }], + }, +]; + +export default apiKeys; diff --git a/packages/spacecat-shared-data-access/test/fixtures/audits.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/audits.fixture.js new file mode 100755 index 00000000..6f81e66e --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/audits.fixture.js @@ -0,0 +1,1411 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const audits = [ + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.01, + seo: 0.56, + accessibility: 0.23, + 'best-practices': 0.09, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/49a5a731-e2f2-41ef-bc5d-bda818c0afa2.json', + auditId: '3fe5ca60-4850-431c-97b3-f88a80f07e9b', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.58, + seo: 0.89, + accessibility: 0.83, + 'best-practices': 0.35, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/d86ff424-76a5-45aa-8bae-817415056802.json', + auditId: '48656b02-62cb-46c0-b271-ee99c940e89e', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.13, + seo: 0.91, + accessibility: 0.38, + 'best-practices': 0.51, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/ace35131-98c8-4578-8bc9-06537f1cffb4.json', + auditId: '5bc610a9-bc59-48d8-937e-4808ade2ecb1', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.1, + seo: 0.34, + accessibility: 0.24, + 'best-practices': 0.6, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/4f861df7-d074-472b-8df8-b96e8c132145.json', + auditId: '62cc5af2-935f-47dd-b60e-87307f39c475', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.51, + seo: 0.3, + accessibility: 0.71, + 'best-practices': 0.63, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/80284b70-0e3c-49f8-b470-8c073f002b7d.json', + auditId: '82250098-ca65-4bef-ada9-71c30102b334', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3815, + FID: 35, + CLS: 0.56, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/9f3ef6ed-d6e6-4fcc-a9ef-fab2e0955104.json', + auditId: '5ab73d44-41ab-4603-8c28-76e2707b3182', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1723, + FID: 49, + CLS: 0.97, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/604e4a2b-47d5-479d-bab0-2bc03b41392a.json', + auditId: 'd141c82e-5290-4352-9a81-a5400436c07c', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1485, + FID: 2, + CLS: 0, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/30486fe9-72f1-4ddb-91c8-8c41cf9e4a3a.json', + auditId: '44d76d98-56cf-4c3d-ab6b-a2a8ee459bed', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1893, + FID: 20, + CLS: 0.35, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/b43b8240-6d83-4aac-9f8e-2ca7d89c1994.json', + auditId: '523396a7-5b30-4e12-a439-ffb1336c6902', + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 714, + FID: 73, + CLS: 0.88, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/82ed94be-979e-4ce3-9c90-1919fefb855a.json', + auditId: '998ec567-d32a-4645-a627-81c20794e6ea', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.25, + seo: 0.53, + accessibility: 0.82, + 'best-practices': 0.92, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/ab0420e5-97fb-48f2-9d9f-90e8d54e08c1.json', + auditId: '00e6591d-f334-4c74-8446-f31c3e689e99', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.19, + seo: 0.33, + accessibility: 0.18, + 'best-practices': 0.71, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/9f190e2c-ed87-43b0-88a5-65480bd90115.json', + auditId: 'b136b63a-5e67-46c0-80b9-68f1699d09c1', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.62, + seo: 0.91, + accessibility: 0.69, + 'best-practices': 0.97, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/04b6b484-ec63-4bd1-9e3b-cf10aa247837.json', + auditId: '759caa14-8a41-4bee-ba87-ec60b8231b6a', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.67, + seo: 0.61, + accessibility: 0.45, + 'best-practices': 0.25, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/4f177b54-9b24-4b99-9fb5-222594819735.json', + auditId: '0bd56305-8486-4b23-abc1-19789efb2807', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.41, + seo: 0, + accessibility: 0.04, + 'best-practices': 0.12, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/32699c46-07cd-4fc0-a71f-4a77356aa3e7.json', + auditId: '73980def-db81-4b5b-b66d-2b94602c2261', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1830, + FID: 66, + CLS: 0.13, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/67cd3ab8-67d6-46be-adc2-c13dea7adcc0.json', + auditId: '54cab615-8608-4d67-a999-b49235217adf', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1398, + FID: 22, + CLS: 0.45, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c70adc49-046f-4ade-ab3e-e72f38f025fe.json', + auditId: '2cc9ab3c-8d46-4ac7-83d7-a1231c91d34c', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2543, + FID: 84, + CLS: 0.34, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/02e9d522-911d-43a6-8d72-11e181c947e0.json', + auditId: '059dcce7-a1a4-4224-904e-fc56620f929d', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 108, + FID: 37, + CLS: 0.32, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/5ce74f5e-a728-4a75-b7f6-4a02b3f25bd7.json', + auditId: '147bd40e-90b5-4e9d-abe1-df30cd16d095', + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3979, + FID: 13, + CLS: 0.12, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/761d24ec-5bb7-4ef6-ab8b-0ce0bc5ac336.json', + auditId: '31e257a7-534e-44ed-90a9-d24c849e246d', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.79, + seo: 0.16, + accessibility: 0.7, + 'best-practices': 0.54, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/9afcfa59-8516-4d76-a960-842ed559eba6.json', + auditId: 'c125fe6e-3768-43a5-ae8f-3448e01c8a1f', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.57, + seo: 0.46, + accessibility: 0.46, + 'best-practices': 0.21, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/bd35024c-cce8-44cc-901c-321c7f25c56e.json', + auditId: '857d3742-0757-4fc0-a7dc-2b73720d37f0', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.45, + seo: 0.8, + accessibility: 0.88, + 'best-practices': 0.33, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/46c92f4e-eb76-4511-b296-cbcb65c47c04.json', + auditId: '8aadfc9f-85e9-4ce8-9b7e-2f9243c57b29', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.76, + seo: 0.89, + accessibility: 0.71, + 'best-practices': 0.51, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/89239d70-b2b6-4776-840d-439963f04a8e.json', + auditId: '29d218aa-416a-4811-866e-0890485d21e0', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.28, + seo: 0.24, + accessibility: 0.64, + 'best-practices': 0.79, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/a937a24e-7eed-4436-8455-176f0e6719c6.json', + auditId: 'c6548198-de76-4a32-8053-e8d101afbd68', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1195, + FID: 0, + CLS: 0.8, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/4674d92c-bf33-424b-9662-95ec1d11cbf7.json', + auditId: '88ee2b0e-61ba-49a3-a2b4-79163418fe87', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 187, + FID: 16, + CLS: 0.55, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/84734df6-3fd6-47fd-bd13-339b8fe22298.json', + auditId: '5b6e75e7-a0c0-414a-bc4c-16543e70b61a', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3294, + FID: 18, + CLS: 0.27, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/f73d8165-5197-43ab-a09d-ad950a5e6ce7.json', + auditId: '7c70acfb-f40a-4102-abc2-69f79c720bf9', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3997, + FID: 32, + CLS: 0.16, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/f2428633-0646-41ba-81c5-7fbc81e00a98.json', + auditId: 'cc1755f3-386c-427e-b2eb-e0b3c5515533', + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3730, + FID: 73, + CLS: 0.33, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/a716c3af-3f5a-4fa6-b408-4a5955cd4dd1.json', + auditId: '6d43f172-3e86-45d8-83cd-6d006fb8cdad', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.06, + seo: 0.46, + accessibility: 0.85, + 'best-practices': 0.91, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/dadcdcd7-fe40-4166-91fb-f0f8b2f237da.json', + auditId: '761c7cc8-7ad5-4a24-aae8-90a1b0b47e9a', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.26, + seo: 0.3, + accessibility: 0.1, + 'best-practices': 0.51, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/70df143f-f2a1-43e6-b9b8-3a56e83f67a9.json', + auditId: 'd8e4e662-8148-471e-a1c2-e75be4fb1d1a', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.54, + seo: 0.8, + accessibility: 0.44, + 'best-practices': 0.9, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/eb2a16ab-c44a-4486-b9f3-83447634d6e0.json', + auditId: 'a6bfc5e8-8d9e-4f22-b549-8bf4ca7b5c66', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.72, + seo: 0.55, + accessibility: 0.27, + 'best-practices': 0.02, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/aad60d6a-3be8-425a-8769-07942f4d6ff3.json', + auditId: '9113159b-a93d-4d1f-aa6f-72575eefd3b3', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.63, + seo: 0.48, + accessibility: 0.93, + 'best-practices': 0.12, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/aae172c2-c2e8-4ddb-998e-8890e8298c5f.json', + auditId: 'aba03683-da1d-467b-b7a5-24f857f016e1', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1624, + FID: 42, + CLS: 0.8, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c537c3b4-1156-4397-936f-11aff4e5a22e.json', + auditId: 'aa36a3c7-ed2b-4290-8985-86bdb7fa3881', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 711, + FID: 46, + CLS: 0.32, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/39906f6b-ed1a-4736-814a-013ec919119f.json', + auditId: '90168755-49bc-48a2-b7f3-9852be99c8af', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1213, + FID: 84, + CLS: 0.6, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/14f602ca-48be-4bd5-97e0-ac2eff4a6dd7.json', + auditId: '54d580be-285c-40fb-a3b5-ec91768d4fa2', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2642, + FID: 65, + CLS: 0.03, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/ee38a119-01f1-4ad6-81e5-209b28a76563.json', + auditId: 'b6bc9260-5424-4fdb-9d2c-ed20084f6583', + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2144, + FID: 22, + CLS: 0.06, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/bc82ffe7-c764-4baf-b0b2-4bd815ad756c.json', + auditId: 'dad35375-fc74-482b-bd22-946e1c013fdd', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.1, + seo: 0.04, + accessibility: 0.99, + 'best-practices': 0.3, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/8802e432-7b64-4116-81e6-029076d6250f.json', + auditId: '30dcaef5-49a1-41ec-8656-eee6d6480d0a', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.3, + seo: 0.54, + accessibility: 0.25, + 'best-practices': 0.97, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c6a8d133-b079-4229-99a6-819ed63249ae.json', + auditId: '3fb08b5a-303d-4f2c-8e73-d929a4eff024', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.56, + seo: 0.02, + accessibility: 0.6, + 'best-practices': 0.21, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/6a8dbfed-f957-47ec-ba48-a60b2009d7a0.json', + auditId: '6a87be71-611f-4b05-a6cf-86a57eb349ed', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.04, + seo: 0.32, + accessibility: 0.01, + 'best-practices': 0.97, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/474bd0b2-4faf-4f37-bed4-a670d2a09186.json', + auditId: '43fd913f-5b14-4f18-9cc3-d49891cc4288', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.68, + seo: 0.79, + accessibility: 0.43, + 'best-practices': 0.64, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/1dabbb60-f530-4f53-84c5-0f168b02309b.json', + auditId: '554b8e9d-98b8-4dd5-9d58-c9ee57160530', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2550, + FID: 42, + CLS: 0.49, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/cb9671f5-29ce-49fa-9386-e9512ef72938.json', + auditId: '4f5f307b-a865-4362-bb25-f5e25db64230', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 914, + FID: 91, + CLS: 0.49, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/b61cfab3-12c5-4fe6-a434-5efcec1e1b2d.json', + auditId: '295db381-d5fb-465a-a5da-2d9adbe04038', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 965, + FID: 43, + CLS: 0.63, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/929daee0-ca67-4134-91a0-28a0827655e4.json', + auditId: '4941bddf-dd5e-45cc-9ef8-1c416fd48a5f', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1957, + FID: 69, + CLS: 0.42, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2f7d425f-c806-4e8f-b973-484a3c7e456a.json', + auditId: 'b3431f8b-a338-45c7-a1fd-c4c6bb3bb56a', + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2579, + FID: 38, + CLS: 0.22, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/fc342da0-6bde-4a49-aa8f-66e7e20b8b62.json', + auditId: '67c86f77-1ee0-4441-bdd2-adff90863f57', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.87, + seo: 0.25, + accessibility: 0.21, + 'best-practices': 0.98, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/eb8414da-fb40-4ee5-a9d2-1f88ca8e5cca.json', + auditId: '9b4be774-4585-4980-9f60-0881c6f34954', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.03, + seo: 0.47, + accessibility: 0.3, + 'best-practices': 0.41, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/0cd4b808-0352-420b-b3d5-897c233edbcf.json', + auditId: '3b84b1b1-75ed-42af-acf3-144b9966289a', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.01, + seo: 0.56, + accessibility: 0.47, + 'best-practices': 0.64, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/52afcf01-0309-4bc6-aab6-45771115b983.json', + auditId: '25a953af-2374-4d98-b146-00efb64a08c0', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.16, + seo: 0.25, + accessibility: 0.42, + 'best-practices': 0.52, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/25832af5-2913-4757-8687-61e3fe5abb48.json', + auditId: 'ff7ed730-7304-4ff8-8e49-161504fffbc9', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.5, + seo: 0.83, + accessibility: 0.23, + 'best-practices': 0.48, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/967a1183-88a9-4a34-a207-f32de8e09c87.json', + auditId: '152cbd10-912e-4269-97e0-29915ec41004', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3892, + FID: 15, + CLS: 0.27, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c37892f9-1f9c-45d8-8cd1-98a4d5d6ca78.json', + auditId: 'f19a8348-a864-4f32-b2fc-24a9e477796e', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3471, + FID: 64, + CLS: 0.98, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/e33cc9b6-1e6a-4f57-a676-2c1b54e6af9e.json', + auditId: '88cf9014-19d8-4ed1-a0ab-7162ac3dc735', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3889, + FID: 14, + CLS: 0.31, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/351b1d0b-9059-4ce5-8cea-695b694d26b1.json', + auditId: '1653f24e-42a9-4f43-aaed-8940494eeeb2', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3776, + FID: 74, + CLS: 0.55, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2c3f3b3c-4ec6-4294-ad75-51ef66c3da22.json', + auditId: '3617955b-b575-4af3-80c9-06dacc2b32d5', + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 618, + FID: 43, + CLS: 0.07, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c9f2530c-6ef9-4056-bc93-ecdbf825d50d.json', + auditId: 'f641393c-9533-4b31-9565-6cf2d6fa7448', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.59, + seo: 0.5, + accessibility: 0.15, + 'best-practices': 0.94, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/146a1be4-cada-4953-b2e0-675e129e761f.json', + auditId: '3743317e-d122-430d-ba07-52f3f0e098e0', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.51, + seo: 0.91, + accessibility: 0.08, + 'best-practices': 0.93, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/e7c6a9c8-8379-4b1a-804d-2db7051383ea.json', + auditId: 'b4f8ac21-679d-4c5d-a84b-354e29500e7c', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.65, + seo: 0.16, + accessibility: 0.79, + 'best-practices': 0.84, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/5f47dfbc-64bd-4673-96e4-822f81e046d0.json', + auditId: 'a3159352-0aa1-440a-8983-52b1d4d1728a', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.99, + seo: 0.31, + accessibility: 0.07, + 'best-practices': 0.81, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/0a45a27b-2b30-428b-9306-309771a66533.json', + auditId: '7fae8262-8e15-4776-8f3a-759f94519873', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.49, + seo: 0.43, + accessibility: 0.41, + 'best-practices': 0.78, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/d69c7946-be1c-48e8-bd08-9b445112289d.json', + auditId: 'c758d7a1-5c18-4f31-854e-f386527a4c24', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 279, + FID: 0, + CLS: 0.18, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/475a14ef-8017-43a1-b633-dce3e9f323c9.json', + auditId: '5e267293-a534-4b5a-90b0-424281eaa4d1', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 699, + FID: 96, + CLS: 0.5, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2baaa974-990f-4ce9-b941-43f50ca26106.json', + auditId: 'c3cee208-4d98-4527-8ccd-7b09da29b913', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2319, + FID: 57, + CLS: 0.46, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/5d1c4e35-7165-4751-adb7-0d63b5b4539d.json', + auditId: 'de9f3e43-3a7f-4863-9ae8-44351d917f72', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3871, + FID: 82, + CLS: 0.29, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/de5150f0-536c-4cc8-aca3-aae14b2f2e3f.json', + auditId: '9943f084-f1d0-4b5f-a610-a06b2acd8a84', + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1480, + FID: 46, + CLS: 0.31, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/56e55cc1-9052-4036-a0e3-3d17c06e76e9.json', + auditId: '4bc151ce-86bb-4718-a3e0-4270cf14ab31', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.25, + seo: 0.74, + accessibility: 0.66, + 'best-practices': 0.12, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/349f82bc-03ac-4957-a267-8157e2ffbba7.json', + auditId: 'ef3e04a5-2b1f-449e-979c-55b33b341b3d', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.64, + seo: 0.47, + accessibility: 0.44, + 'best-practices': 0.06, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/c62d53b2-b9ac-4636-b1af-f7c4b982d746.json', + auditId: '3343fd4b-3185-49b4-b6c5-cd75b3a7b342', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.93, + seo: 0.36, + accessibility: 0.56, + 'best-practices': 0.34, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/af269848-fd03-4fe9-a702-22d38c2efd4b.json', + auditId: '6c7c0771-2561-44c8-bd58-5a61ab2227cf', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.01, + seo: 0.92, + accessibility: 0.63, + 'best-practices': 0.55, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/bcd8b08f-35aa-4d5a-aff6-8f184434b8e2.json', + auditId: 'fde1401c-f2e4-4250-ae41-ca637a2fbcfd', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.33, + seo: 0.8, + accessibility: 0.96, + 'best-practices': 0.22, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/fc312358-b9b9-4268-911a-c63f709baa3b.json', + auditId: '4d38967a-85c0-4e89-a20f-4f247b2a1bb8', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 380, + FID: 67, + CLS: 0.25, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/0440c39c-f15e-4b71-8a74-60f4ece1478c.json', + auditId: 'ac445f94-441b-46b4-9ce9-f1cc2e9390ea', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3996, + FID: 59, + CLS: 0.27, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/25c8bc2f-ec0c-432e-87bd-6b608d46bcf4.json', + auditId: 'f055cd12-0f2b-4043-8f34-bc892c4175a0', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3317, + FID: 92, + CLS: 0.76, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/52c305f5-f943-45a1-87d7-caf396a50c61.json', + auditId: '6f8d6d0c-7cf5-46cb-90a0-d864362ed5f5', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 729, + FID: 97, + CLS: 0.13, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2045f404-51c5-4f2f-a344-468d6be86f87.json', + auditId: 'fc688025-4fa9-4a77-b958-8f8b8ecce657', + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 496, + FID: 45, + CLS: 0.82, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/ec87b5b7-88c1-4f0d-8aff-a7ac33fc7401.json', + auditId: '90ded6b5-f45b-4ef9-b66b-876f64ecd9cc', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.12, + seo: 0.99, + accessibility: 0.3, + 'best-practices': 0.89, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/6e86047d-d210-44ed-9f62-ab75e6ff3d3a.json', + auditId: 'f3749899-7fc5-4b05-b467-1e2410471713', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.27, + seo: 0.87, + accessibility: 0.47, + 'best-practices': 0.1, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/6b54f635-5d1a-4f02-a1cb-e3e970444b9e.json', + auditId: '2320706e-a629-42df-82d2-e032478a999a', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.33, + seo: 0.8, + accessibility: 0.88, + 'best-practices': 0.94, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/5a60159a-3cec-4ef1-8e45-89d558e5f5c5.json', + auditId: 'ff435772-20e6-47b4-96ae-e37c0016a749', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.31, + seo: 0.99, + accessibility: 0.19, + 'best-practices': 0.82, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/81acc17a-3fb5-4392-870a-1da8d28e2aeb.json', + auditId: '5e6e5a16-67b9-4e5e-bb6d-e8ae3436a69e', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'lhs-mobile', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + performance: 0.62, + seo: 0.3, + accessibility: 0.25, + 'best-practices': 0.44, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/472d9af6-1a0d-45a6-a3ae-ab1f239dae4a.json', + auditId: '65a6e8ea-7d26-4d9b-80db-b2c2b96bddfb', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2357, + FID: 5, + CLS: 0.49, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2c140c93-cd08-4ded-94e6-d3be6238adb6.json', + auditId: '32f6355b-d187-4436-bec0-9a5e5ae3ba0c', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1542, + FID: 96, + CLS: 0.56, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/911dffec-7318-4665-a8e8-0ae799ca0f9a.json', + auditId: '0a9c4880-2e91-42ae-ad8b-4bb7df1dba9c', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 1996, + FID: 90, + CLS: 0.52, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/db57f2b8-4ff6-429c-ad43-4b3360288278.json', + auditId: 'bcfbdc07-f665-415b-925d-7d30409769ca', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 3898, + FID: 65, + CLS: 0.54, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/8d783998-f58f-4199-89a1-f6c240318bd3.json', + auditId: '24bf0a9d-efc3-4585-ad5e-88c4037be72d', + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + auditType: 'cwv', + auditedAt: '2024-12-03T08:00:55.754Z', + auditResult: { + scores: { + LCP: 2464, + FID: 28, + CLS: 0.85, + }, + }, + isLive: true, + fullAuditRef: 's3://audit-results/2e074190-3e94-4545-b099-b63dcf443565.json', + auditId: '144d0a42-05cd-4166-a879-cca18dc0b31a', + }, +]; + +export default audits; diff --git a/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js new file mode 100644 index 00000000..bcdf0452 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js @@ -0,0 +1,111 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const configurations = [ + { + configurationId: '3c29b306-5075-4a2d-a965-730d0e565e7f', + jobs: [ + { + group: 'audits', + type: 'lhs-mobile', + interval: 'daily', + }, + { + group: 'audits', + type: '404', + interval: 'daily', + }, + { + group: 'imports', + type: 'rum-ingest', + interval: 'daily', + }, + { + group: 'reports', + type: '404-external-digest', + interval: 'weekly', + }, + { + group: 'audits', + type: 'apex', + interval: 'weekly', + }, + ], + handlers: { + 404: { + enabledByDefault: true, + }, + 'organic-keywords': { + enabledByDefault: false, + }, + cwv: { + enabledByDefault: true, + disabled: { + sites: [ + '5d6d4439-6659-46c2-b646-92d110fa5a52', + '78fec9c7-2141-4600-b7b1-ea5c78752b91', + '56a691db-d32e-4308-ac99-a21de0580557', + '196fb401-ede2-4607-9d25-7c011a65d143', + 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + '3429cedf-06b0-489f-b066-81cada1634fc', + '73bd9bba-40bb-4249-bc69-7ea0f130481d', + 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + 'b197d10e-035e-433b-896f-8e4967c5de6a', + ], + orgs: ['757ceb98-05c8-4e07-bb23-bc722115b2b0'], + }, + }, + 'lhs-mobile': { + enabledByDefault: false, + enabled: { + sites: ['c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe'], + orgs: ['757ceb98-05c8-4e07-bb23-bc722115b2b0'], + }, + }, + }, + queues: { + audits: 'sqs://.../spacecat-services-audit-jobs', + imports: 'sqs://.../spacecat-services-import-jobs', + reports: 'sqs://.../spacecat-services-report-jobs', + }, + slackRoles: { + scrape: [ + 'WSVT1K36Z', + 'S03CR0FDC2V', + ], + }, + version: 2, + }, + { + configurationId: 'a76a5b01-d065-4349-a28f-f1beaf96aee6', + jobs: [ + { + group: 'audits', + type: 'lhs-mobile', + interval: 'daily', + }, + { + group: 'reports', + type: '404-external-digest', + interval: 'weekly', + }, + ], + queues: { + audits: 'sqs://.../spacecat-services-audit-jobs', + reports: 'sqs://.../spacecat-services-report-jobs', + }, + version: 1, + }, +]; + +export default configurations; diff --git a/packages/spacecat-shared-data-access/test/fixtures/experiments.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/experiments.fixture.js new file mode 100755 index 00000000..1b1ca121 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/experiments.fixture.js @@ -0,0 +1,136 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const experiments = [ + { + experimentId: '745292e2-52af-4b66-b63b-fca68019a42b', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + expId: 'experiment-1', + name: 'Experiment 1', + url: 'https://example0.com/page-1', + status: 'ACTIVE', + type: 'full', + variants: [ + { + label: 'Challenger 1', + name: 'challenger-1', + interactionsCount: 10, + p_value: 'coming soon', + split: 0.8, + url: 'https://example0.com/page-1/variant-1', + views: 100, + metrics: [ + { + selector: '.header .button', + type: 'click', + value: 2, + }, + ], + }, + { + label: 'Challenger 2', + name: 'challenger-2', + interactionsCount: 20, + p_value: 'coming soon', + metrics: [], + split: 0.8, + url: 'https://example0.com/page-2/variant-2', + views: 200, + }, + ], + startDate: '2024-11-29T07:45:55.952Z', + endDate: '2024-12-09T07:45:55.954Z', + updatedBy: 'scheduled-experiment-audit', + }, + { + experimentId: '3451b539-df79-4033-b300-82904f7a3840', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + expId: 'experiment-2', + name: 'Experiment 2', + url: 'https://example0.com/page-2', + status: 'ACTIVE', + type: 'full', + variants: [ + { + label: 'Challenger 2', + name: 'challenger-2', + interactionsCount: 20, + p_value: 'coming soon', + split: 0.8, + url: 'https://example0.com/page-2/variant-2', + views: 200, + metrics: [ + { + selector: '.header .button', + type: 'click', + value: 4, + }, + ], + }, + { + label: 'Challenger 3', + name: 'challenger-3', + interactionsCount: 30, + p_value: 'coming soon', + metrics: [], + split: 0.8, + url: 'https://example0.com/page-3/variant-3', + views: 300, + }, + ], + startDate: '2024-11-29T07:45:55.952Z', + endDate: '2024-12-09T07:45:55.954Z', + updatedBy: 'scheduled-experiment-audit', + }, + { + experimentId: '111385e5-5680-48bd-8a77-f6b69df6f1b7', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + expId: 'experiment-3', + name: 'Experiment 3', + url: 'https://example0.com/page-3', + status: 'ACTIVE', + type: 'full', + variants: [ + { + label: 'Challenger 3', + name: 'challenger-3', + interactionsCount: 30, + p_value: 'coming soon', + split: 0.8, + url: 'https://example0.com/page-3/variant-3', + views: 300, + metrics: [ + { + selector: '.header .button', + type: 'click', + value: 6, + }, + ], + }, + { + label: 'Challenger 4', + name: 'challenger-4', + interactionsCount: 40, + p_value: 'coming soon', + metrics: [], + split: 0.8, + url: 'https://example0.com/page-4/variant-4', + views: 400, + }, + ], + startDate: '2024-11-29T07:45:55.952Z', + endDate: '2024-12-09T07:45:55.954Z', + updatedBy: 'scheduled-experiment-audit', + }, +]; + +export default experiments; diff --git a/packages/spacecat-shared-data-access/test/fixtures/import-jobs.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/import-jobs.fixture.js new file mode 100644 index 00000000..e38562e9 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/import-jobs.fixture.js @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ImportJobStatus, ImportOptions } from '../../src/index.js'; + +const importJobs = [ + { + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + importQueueId: 'Q-123', + hashedApiKey: '1234', + baseURL: 'https://example-1.com/cars', + startedAt: '2023-12-06T08:17:41.467Z', + status: ImportJobStatus.RUNNING, + initiatedBy: { + apiKeyName: 'K-123', + }, + options: { + [ImportOptions.ENABLE_JAVASCRIPT]: true, + }, + hasCustomImportJs: true, + hasCustomHeaders: false, + }, + { + importJobId: '72113a4d-ca45-4c35-bd2e-29bb0ec03435', + importQueueId: 'Q-321', + hashedApiKey: '4321', + baseURL: 'https://example-2.com/cars', + startedAt: '2023-11-15T01:22:05.000Z', + status: ImportJobStatus.FAILED, + initiatedBy: { + apiKeyName: 'K-321', + }, + options: { + [ImportOptions.ENABLE_JAVASCRIPT]: false, + }, + hasCustomImportJs: false, + hasCustomHeaders: true, + }, + { + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + importQueueId: 'Q-213', + hashedApiKey: '4231', + baseURL: 'https://example-3.com/', + startedAt: '2023-11-15T03:46:40.000Z', + endedAt: '2023-11-15T03:49:13.000Z', + status: ImportJobStatus.COMPLETE, + initiatedBy: { + apiKeyName: 'K-322', + }, + options: { + [ImportOptions.ENABLE_JAVASCRIPT]: false, + }, + hasCustomImportJs: false, + hasCustomHeaders: true, + }, +]; + +export default importJobs; diff --git a/packages/spacecat-shared-data-access/test/fixtures/import-urls.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/import-urls.fixture.js new file mode 100644 index 00000000..207bf1a2 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/import-urls.fixture.js @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ImportUrlStatus } from '../../src/index.js'; + +const importUrls = [ + { + importUrlId: 'dd92aba6-5509-44a5-afbb-f56e6c4544ed', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/1', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: '531b69c9-0059-42cf-a19d-302d932e22c7', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/2', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: '4cb51b53-f8c6-4975-841d-6ca54489aba4', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/3', + status: ImportUrlStatus.PENDING, + }, + { + importUrlId: '7aab39a1-a677-461c-a79c-ee7d64c4dd35', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/4', + status: ImportUrlStatus.PENDING, + }, + { + importUrlId: '5ffc1fa0-9920-43c5-8228-f13354dd2f25', + importJobId: '021cbb7d-0772-45c6-967c-86a0a598b7dd', + url: 'https://example-1.com/cars/5', + status: ImportUrlStatus.FAILED, + }, + // 2 + { + importUrlId: '59896102-0f4b-4fff-a4cb-e45fd3b5b6b0', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-2.com/cars/1', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: '033f7342-c49e-45fd-8026-19b8220bf887', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-2.com/cars/2', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: 'f38a0810-21c9-4bf6-bdeb-6c0c32c38f62', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-2.com/cars/3', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: '480f058f-dde3-4149-ace0-25b14f13d597', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-2.com/cars/4', + status: ImportUrlStatus.COMPLETE, + }, + { + importUrlId: 'c5b2c409-6074-4379-a06d-06ca85e8b5d6', + importJobId: '78e1f8de-661a-418b-bd80-24589a10b5ce', + url: 'https://example-1.com/cars/5', + status: ImportUrlStatus.STOPPED, + }, +]; + +export default importUrls; diff --git a/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js new file mode 100644 index 00000000..222b78f6 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/index.fixtures.js @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import apiKeys from './api-keys.fixtures.js'; +import audits from './audits.fixture.js'; +import configurations from './configurations.fixture.js'; +import experiments from './experiments.fixture.js'; +import importJobs from './import-jobs.fixture.js'; +import importUrls from './import-urls.fixture.js'; +import keyEvents from './key-events.fixture.js'; +import opportunities from './opportunities.fixture.js'; +import organizations from './organizations.fixture.js'; +import siteCandidates from './site-candidates.fixture.js'; +import siteTopPages from './site-top-pages.fixture.js'; +import sites from './sites.fixture.js'; +import suggestions from './suggestions.fixture.js'; + +export default { + apiKeys, + audits, + configurations, + experiments, + importJobs, + importUrls, + keyEvents, + opportunities, + organizations, + siteCandidates, + siteTopPages, + sites, + suggestions, +}; diff --git a/packages/spacecat-shared-data-access/test/fixtures/key-events.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/key-events.fixture.js new file mode 100755 index 00000000..48326ac0 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/key-events.fixture.js @@ -0,0 +1,716 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const keyEvents = [ + { + keyEventId: '3b2d9cb2-5610-4b49-b138-0a1ff45221d1', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4061ea02-e03a-46e9-9443-a9ef412c79c5', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '67c4b2d2-658f-4e68-beab-cccd41df9e1e', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '2b0f0503-fa45-440f-afda-4877194afcb0', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '480e907b-4409-4ff8-b7ce-f65320381b8e', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5ceb13e6-75ec-4cf1-90ee-4e8113e2f2c2', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '8b32b3b3-004d-4c8a-a536-ff1501392b92', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'be4a159e-d44d-49ce-8900-7de644632f9f', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '21c77742-4249-4ad5-a747-7abe5154fa2b', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'f4c42af3-4b18-404f-bf88-d87155eaf640', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4581066b-250a-422a-92d9-995dc9b0d4e4', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b4d3a7ca-59ce-4e6c-ac28-a9a1bf17777c', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'fffd8fc5-a007-47a6-9569-594ce3bd1e8d', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b740d9f0-3652-4430-a7f7-5941f1b420ab', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '440e092d-4f13-4fa5-b564-7713edd27fd0', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e72558e7-0856-4565-bb77-353f1e75e1c7', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '7c2b17f8-40b5-4a3e-bcba-ed0a1d6571d6', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0f176d01-cb8d-47c3-99c3-c3298469de71', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5bdea93b-96a1-4533-a5e9-a19135332077', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '22b86430-f63a-4ecf-b61c-e9417bb5f489', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'a75743a2-1b85-4b9d-9be0-9bf61185c2a9', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'a1e7c306-ceca-4fcd-bb96-88eb55205759', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '47dfb1ba-c3b9-4a68-88a3-9a8406665910', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '9c9c6f9d-7674-4da6-9cc7-3510a771db15', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0645e469-aac3-423b-b390-c3f402eaa4ec', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'eee6e3e4-c70c-4224-9b04-d81520e3138a', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '36aeacfd-5d50-482d-9a17-b4bfe7b09db4', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '1a4afdda-db0b-4ff3-91a1-86e822cb16d6', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '8f6d504d-6673-494b-85de-3e04f7105a71', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '402eeeca-6447-4c75-9786-0d93e03ce287', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '78903cbf-0fb1-4c4b-8da0-0e4afc4facc8', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '17b635af-0745-49ae-bae5-dd793e61979c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0bf69424-b638-4062-ab11-9d9d06c03445', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '84cfcbd5-23e5-4ce1-9a8f-8a599fd63d53', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0d2bebd1-54c3-43a9-938d-c7610b1adf6d', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '57356d04-1b6c-4de7-8e83-8ff9fcccd81c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '9788a4c8-1c19-4684-8672-b287ca1cfc40', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'fb716741-d971-4514-b59b-2473261a579c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '3ad1595f-f403-4b76-8184-8f811d71b01c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '681c9e03-c56c-40e5-88e8-343fb797c212', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'c4e63c52-c415-4ff1-9c8a-023871595155', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '50546ecc-114b-46bb-9a7a-9b48ce3cc520', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '22d2d6f5-aefb-4a7a-baa6-cdcad14bc870', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0848dc29-4020-415f-a773-d2c8b029454d', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '0e0b5396-01ec-4f55-8a14-46d39689f852', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e56724f8-a75f-4c3b-88ca-f28e513c9b68', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '764894f0-cc55-491f-ae9b-d1fc07c56041', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'd92d0b8e-1216-4d50-b659-42c3a8f46dbf', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'd73e6a54-d7aa-47db-b6df-b45685c9dc9e', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '103863f5-67c7-4b04-9d8a-aad840df7175', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '7d5adc14-6e8c-4376-bc90-68a4bf5cd6bd', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '27940406-877b-45af-beb3-4ba2c49d4316', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'c4a691b9-48d5-44d5-9ac1-8e1d9b4c46e4', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4d83f283-6b83-4785-b1fa-f522ceb9fa57', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '52a19d57-f746-492d-8241-aaf3d697ef15', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b04d4630-c4a4-4c05-a1b5-3e54ddd70694', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '8a15adc7-dc62-46c7-8d46-a189f59c9865', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'a57a1442-7819-42d0-91df-f42469d50dbb', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b083ea44-0966-41bb-a54d-ccb519f3e74c', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4f9e24af-737a-494f-aa3f-4413dd3bfa03', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'fbdee0fe-5782-4f23-98a7-3010706fb191', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'd3ba693c-e664-49a2-a95e-8e320aa24896', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e99f12ef-17e3-42f4-94f5-21208cdf2e45', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e59f7864-9012-49a4-8cd2-d3a5e9231fc1', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '04fab020-29b7-4ca1-aa8e-86d90d4c5fa7', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '8ba99dd5-fcff-4d4e-af41-0df6877ca012', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '3443a217-a6c1-4710-9387-31b307fecc7c', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e708be0a-69b8-4ae6-af98-7ed9671446c2', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'c5db543d-2364-4d50-bd95-54fac1498e2c', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '79bbe5f8-8a98-49ba-a1b1-b7ae0ad91af5', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '9c19aea5-8205-436e-b404-fcab49d4040f', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5e85d6f4-3f30-463a-9e14-060d217cc8d1', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'f1343c61-f242-46d2-b31a-afa536a4f632', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'cbfcce79-8398-4e90-aecb-00057631c132', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '76b0f029-53e5-49a6-b92e-03cdf2e5bc2c', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b652a304-8cc9-4ff6-889c-3b48abf6cf68', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '3e030a5b-7b8a-456c-9f0d-e3e1850889c8', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '640fdf68-d92b-4b56-a55e-2ff337780a16', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'a5d2a5d7-f83d-4832-a87e-30cde0585c0d', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4f6df4e7-7a6f-4e93-aba4-54de4b3c34f3', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '142d8755-aabb-4919-8e1b-9f18a4811764', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b9e20c4e-6a07-4bbf-9881-842605b925f9', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'fe66e789-0566-4abc-938d-4af7303d3a30', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '9940d958-6b6e-48c8-b680-b2f166bc29b3', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'd02fee8f-5301-4737-84f3-b23eea9dd0e2', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b348bd8c-059e-4fe2-ab93-1f262284aeec', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '534fea70-c9ca-4768-8599-9214cd86a7f0', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e72d4510-15e1-4c77-bb3c-3206124814d1', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'ec0e865d-b4aa-4297-a213-f9e1cab0e557', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'e41b7b1f-b7d9-4999-a678-f0fd588239b4', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '111860ee-1b0d-47b2-9a52-32d9c38569be', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#0', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '922fef39-61e8-4208-a039-94daf0d43b34', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#1', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '7e27e616-db48-4945-ad72-d2fa8491340a', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#2', + type: 'CONTENT', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: 'b0799c94-44e6-4ffd-9234-af75d8cf71aa', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#3', + type: 'CODE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '6739a542-a962-41f1-a45f-e89233eb2f82', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#4', + type: 'THIRD PARTY', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5e782e9b-f2f2-459b-8bf4-7f4ee36e5b9f', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#5', + type: 'EXPERIMENTATION', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '4f05cd5a-79fd-47f2-bfa9-7adf11035a74', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#6', + type: 'NETWORK', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '510b7f04-30d9-461a-8a5b-14cdb4a8330b', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#7', + type: 'STATUS CHANGE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '5704be25-2836-44ca-8e1e-bda3823be0f3', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#8', + type: 'PERFORMANCE', + time: '2024-11-29T07:45:55.953Z', + }, + { + keyEventId: '553ef634-9a08-416f-848f-6cc97384d9fc', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + name: 'key-event-#9', + type: 'SEO', + time: '2024-11-29T07:45:55.953Z', + }, +]; + +export default keyEvents; diff --git a/packages/spacecat-shared-data-access/test/fixtures/opportunities.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/opportunities.fixture.js new file mode 100644 index 00000000..902bf354 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/opportunities.fixture.js @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const opportunities = [ + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + auditId: '3fe5ca60-4850-431c-97b3-f88a80f07e9b', + opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', + title: 'Opportunity 0', + description: 'Description 0', + runbook: 'https://example0.com', + type: 'broken-backlinks', + origin: 'AI', + guidance: { + foo: 'bar-0', + }, + status: 'NEW', + data: { + brokenLinks: [ + 'foo-0', + ], + }, + }, + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + auditId: '48656b02-62cb-46c0-b271-ee99c940e89e', + opportunityId: '742c49a7-d61f-4c62-9f7c-3207f520ed1e', + title: 'Opportunity 1', + description: 'Description 1', + runbook: 'https://example1.com', + type: 'broken-internal-links', + origin: 'AI', + guidance: { + foo: 'bar-1', + }, + status: 'IN_PROGRESS', + data: { + brokenInternalLinks: [ + 'bar-1', + ], + }, + }, + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + auditId: '5bc610a9-bc59-48d8-937e-4808ade2ecb1', + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + title: 'Opportunity 2', + description: 'Description 2', + runbook: 'https://example2.com', + type: 'broken-backlinks', + origin: 'AI', + guidance: { + foo: 'bar-2', + }, + status: 'NEW', + data: { + brokenLinks: [ + 'foo-2', + ], + }, + }, +]; + +export default opportunities; diff --git a/packages/spacecat-shared-data-access/test/fixtures/organizations.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/organizations.fixture.js new file mode 100644 index 00000000..6fd5c85b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/organizations.fixture.js @@ -0,0 +1,106 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const organizations = [ + { + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + imsOrgId: '0-1234@AdobeOrg', + name: '0-1234Name', + config: + { + slack: + { + workspace: '0-workspace', + channel: '0-channel', + }, + handlers: + { + 404: + { + mentions: + { + slack: + [ + '0-slackId', + ], + }, + }, + 'organic-keywords': + { + country: 'RO', + }, + }, + }, + }, + { + organizationId: '757ceb98-05c8-4e07-bb23-bc722115b2b0', + imsOrgId: '1-1234@AdobeOrg', + name: '1-1234Name', + config: + { + slack: + { + workspace: '1-workspace', + channel: '1-channel', + }, + handlers: + { + 404: + { + mentions: + { + slack: + [ + '1-slackId', + ], + }, + }, + 'organic-keywords': + { + country: 'RO', + }, + }, + }, + }, + { + organizationId: '5d42bdf8-b65d-4de8-b849-a4f28ebc93cd', + imsOrgId: '2-1234@AdobeOrg', + name: '2-1234Name', + config: + { + slack: + { + workspace: '2-workspace', + channel: '2-channel', + }, + handlers: + { + 404: + { + mentions: + { + slack: + [ + '2-slackId', + ], + }, + }, + 'organic-keywords': + { + country: 'RO', + }, + }, + }, + }, +]; + +export default organizations; diff --git a/packages/spacecat-shared-data-access/test/fixtures/site-candidates.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/site-candidates.fixture.js new file mode 100644 index 00000000..0488ff42 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/site-candidates.fixture.js @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const siteCandidates = [ + { + siteCandidateId: 'aa3f78ad-e76f-437d-a4e5-9702fe5e80e2', + baseURL: 'https://example0.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'ddc56466-fd1f-49e0-8e6f-aa2e018b5c32', + baseURL: 'https://example1.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: '37ed9927-f2da-4f00-b35a-7c994e3dc73e', + baseURL: 'https://example2.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'bf960709-9ed6-4a39-9804-90cb10824ebe', + baseURL: 'https://example3.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: '4ef9108a-3a89-499f-9d25-85ebc06996f8', + baseURL: 'https://example4.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'ae726216-b4e9-4fad-928c-bdda9a103d7c', + baseURL: 'https://example5.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'a54d1d04-0696-4c85-989e-4670abbb7fa6', + baseURL: 'https://example6.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: '926ea990-7ce2-4ad7-ac8f-444846f004c8', + baseURL: 'https://example7.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'c1a48f74-0b1c-48f3-a4b6-4db128f00226', + baseURL: 'https://example8.com', + status: 'PENDING', + source: 'CDN', + }, + { + siteCandidateId: 'ae28b64f-9eaa-40f3-93af-033953234a9f', + baseURL: 'https://example9.com', + status: 'PENDING', + source: 'CDN', + }, +]; + +export default siteCandidates; diff --git a/packages/spacecat-shared-data-access/test/fixtures/site-top-pages.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/site-top-pages.fixture.js new file mode 100644 index 00000000..f221bab3 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/site-top-pages.fixture.js @@ -0,0 +1,516 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const siteTopPages = [ + { + siteTopPageId: 'fe732596-d16a-451d-80a2-f4283beb6ee7', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-0', + traffic: 12345, + topKeyword: 'keyword-0', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '688098d1-7778-4857-8ef9-57bc8dd110b8', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-1', + traffic: 24690, + topKeyword: 'keyword-1', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '254a0948-b3c8-45ed-9f47-30158175c77f', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-2', + traffic: 37035, + topKeyword: 'keyword-2', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '7ec3f862-c23a-4332-9892-034efc3c3cda', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-3', + traffic: 49380, + topKeyword: 'keyword-3', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'bebd496d-e230-438e-be32-a9244231dd44', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-4', + traffic: 61725, + topKeyword: 'keyword-4', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '3f28c46f-77f4-4744-8968-c21d8d3553b9', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-5', + traffic: 74070, + topKeyword: 'keyword-5', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'f72c0fb1-3e9b-422d-9a20-4b6e65d1936d', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-6', + traffic: 86415, + topKeyword: 'keyword-6', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '8ad6df6f-69f0-4a39-b4ba-d6486b1329dd', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-7', + traffic: 98760, + topKeyword: 'keyword-7', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'cb959983-ac42-44d8-a767-9b7a368072ca', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-8', + traffic: 111105, + topKeyword: 'keyword-8', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'adba4e9b-c7b5-49a3-b678-a60a15b273f7', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-9', + traffic: 123450, + topKeyword: 'keyword-9', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '1a55e463-f453-4da3-9a11-ab6eb1f38960', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-10', + traffic: 135795, + topKeyword: 'keyword-10', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '55f9b70c-d501-4dee-bdd8-41cfbc663961', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-11', + traffic: 148140, + topKeyword: 'keyword-11', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'c840e7dd-3532-4059-8b0c-611e79bd57a6', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-12', + traffic: 160485, + topKeyword: 'keyword-12', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '9c0f2340-005e-4832-849d-430561fdbf4c', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-13', + traffic: 172830, + topKeyword: 'keyword-13', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'b25de0e7-055c-41fc-97e6-7f242aff908e', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-14', + traffic: 185175, + topKeyword: 'keyword-14', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '4066eac5-70e7-4d0f-a1d5-0c79e2c20b7a', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-15', + traffic: 197520, + topKeyword: 'keyword-15', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '0b4d3adf-d162-4f6a-a391-b0ac63cab6cb', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-16', + traffic: 209865, + topKeyword: 'keyword-16', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '1306bb98-cfa4-4c49-9f0e-f7b0821220cc', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-17', + traffic: 222210, + topKeyword: 'keyword-17', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '3ecccce8-b72e-4c4e-8923-d76c91ecf9c6', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-18', + traffic: 234555, + topKeyword: 'keyword-18', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'd204c26a-682d-4218-8e49-90d7229dcd49', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-19', + traffic: 246900, + topKeyword: 'keyword-19', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'c89ba829-6c4b-43fe-af47-dc1538424192', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-20', + traffic: 259245, + topKeyword: 'keyword-20', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'd6ac93ad-b5b9-405d-a2bf-41c1c4ef8371', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-21', + traffic: 271590, + topKeyword: 'keyword-21', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '8fd720ef-8e22-4c32-bda0-f0a52fc17d6a', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-22', + traffic: 283935, + topKeyword: 'keyword-22', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '4d830289-9912-47c1-8151-f511b35593cf', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-23', + traffic: 296280, + topKeyword: 'keyword-23', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'b6e83c2e-657e-431d-9cf6-f9423fbf9fa5', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-24', + traffic: 308625, + topKeyword: 'keyword-24', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '202b661e-fddb-4dd4-a4cd-0212b9558da2', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-25', + traffic: 320970, + topKeyword: 'keyword-25', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'ee59ac4b-ea4b-4e88-88c3-012a35de1601', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-26', + traffic: 333315, + topKeyword: 'keyword-26', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'de4d24fa-18a5-4735-894d-b09d259b9b37', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-27', + traffic: 345660, + topKeyword: 'keyword-27', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '942ddc5a-1d2e-4339-9f24-70e98577333e', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-28', + traffic: 358005, + topKeyword: 'keyword-28', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '5da258e5-e992-4933-a389-542866badd6e', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-29', + traffic: 370350, + topKeyword: 'keyword-29', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '5ea4b10f-00d1-4688-bf67-6fb3a0eeeec1', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-30', + traffic: 382695, + topKeyword: 'keyword-30', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '9689dac9-9c66-44f2-b4dc-476183f2002a', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-31', + traffic: 395040, + topKeyword: 'keyword-31', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'e8d623cc-78aa-4a6e-a1df-38a8f1bf096b', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-32', + traffic: 407385, + topKeyword: 'keyword-32', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '1efce2b3-95a9-47a4-bfb8-158bedf728c7', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-33', + traffic: 419730, + topKeyword: 'keyword-33', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '47580cec-37f6-4655-b344-5cfad4a6f30a', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-34', + traffic: 432075, + topKeyword: 'keyword-34', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '3160fc0e-43c3-48a9-abcc-91ab24ded702', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-35', + traffic: 444420, + topKeyword: 'keyword-35', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '2e3558fd-f992-4f49-99c6-e9448bb06b71', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-36', + traffic: 456765, + topKeyword: 'keyword-36', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '0547dba8-e3f4-4d45-9703-5e00602c1f00', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-37', + traffic: 469110, + topKeyword: 'keyword-37', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'e1815da4-3653-4fd3-9960-f0482b283c68', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-38', + traffic: 481455, + topKeyword: 'keyword-38', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'e1af7167-b886-4891-b91a-f21cf818c134', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-39', + traffic: 493800, + topKeyword: 'keyword-39', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '7beddc90-c87d-4350-a869-1c43be3f4ac6', + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + url: 'https://example0.com/page-40', + traffic: 506145, + topKeyword: 'keyword-40', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '3318c08f-4fc1-4c81-be9b-3116c2396820', + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + url: 'https://example1.com/page-41', + traffic: 518490, + topKeyword: 'keyword-41', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '4b8b27db-1d35-47fe-92b1-c684b3627446', + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + url: 'https://example2.com/page-42', + traffic: 530835, + topKeyword: 'keyword-42', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'b082fe9c-4e90-4abc-8279-9b0f570821b0', + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + url: 'https://example3.com/page-43', + traffic: 543180, + topKeyword: 'keyword-43', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'cc49c6fd-87ec-493d-8cc8-a3426296947e', + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + url: 'https://example4.com/page-44', + traffic: 555525, + topKeyword: 'keyword-44', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'f856cbda-dce7-46e8-b000-bb696a02c16b', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + url: 'https://example5.com/page-45', + traffic: 567870, + topKeyword: 'keyword-45', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '5bb47ff2-3c37-432f-b924-59df5e840150', + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + url: 'https://example6.com/page-46', + traffic: 580215, + topKeyword: 'keyword-46', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '1e2e0ccd-1f8b-45fe-aab7-1d05dc84e848', + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + url: 'https://example7.com/page-47', + traffic: 592560, + topKeyword: 'keyword-47', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: 'c71f49bc-f216-4eff-8f37-e9e1cef741b4', + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + url: 'https://example8.com/page-48', + traffic: 604905, + topKeyword: 'keyword-48', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, + { + siteTopPageId: '930340a7-3d1e-4039-993c-778de6b8c80c', + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + url: 'https://example9.com/page-49', + traffic: 617250, + topKeyword: 'keyword-49', + source: 'ahrefs', + geo: 'global', + importedAt: '2024-11-29T07:45:55.953Z', + }, +]; + +export default siteTopPages; diff --git a/packages/spacecat-shared-data-access/test/fixtures/sites.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/sites.fixture.js new file mode 100644 index 00000000..04d7486c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/sites.fixture.js @@ -0,0 +1,424 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const sites = [ + { + siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', + baseURL: 'https://example0.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-0/test-repo', + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + config: + { + slack: + { + workspace: '0-workspace', + channel: '0-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '0-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '78fec9c7-2141-4600-b7b1-ea5c78752b91', + baseURL: 'https://example1.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-1/test-repo', + organizationId: '757ceb98-05c8-4e07-bb23-bc722115b2b0', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '1-workspace', + channel: '1-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '1-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '56a691db-d32e-4308-ac99-a21de0580557', + baseURL: 'https://example2.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-2/test-repo', + organizationId: '5d42bdf8-b65d-4de8-b849-a4f28ebc93cd', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '2-workspace', + channel: '2-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '2-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '196fb401-ede2-4607-9d25-7c011a65d143', + baseURL: 'https://example3.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-3/test-repo', + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '3-workspace', + channel: '3-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '3-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + baseURL: 'https://example4.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-4/test-repo', + organizationId: '757ceb98-05c8-4e07-bb23-bc722115b2b0', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '4-workspace', + channel: '4-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '4-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + baseURL: 'https://example5.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-5/test-repo', + organizationId: '5d42bdf8-b65d-4de8-b849-a4f28ebc93cd', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '5-workspace', + channel: '5-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '5-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '3429cedf-06b0-489f-b066-81cada1634fc', + baseURL: 'https://example6.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-6/test-repo', + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '6-workspace', + channel: '6-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '6-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: '73bd9bba-40bb-4249-bc69-7ea0f130481d', + baseURL: 'https://example7.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-7/test-repo', + organizationId: '757ceb98-05c8-4e07-bb23-bc722115b2b0', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '7-workspace', + channel: '7-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '7-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: 'fbb8fcba-e7d3-4ed7-8623-19e88b1f0ed5', + baseURL: 'https://example8.com', + deliveryType: 'aem_edge', + gitHubURL: 'https://github.com/org-8/test-repo', + organizationId: '5d42bdf8-b65d-4de8-b849-a4f28ebc93cd', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '8-workspace', + channel: '8-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '8-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, + { + siteId: 'b197d10e-035e-433b-896f-8e4967c5de6a', + baseURL: 'https://example9.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://github.com/org-9/test-repo', + organizationId: '4854e75e-894b-4a74-92bf-d674abad1423', + isLive: true, + isLiveToggledAt: '2024-11-29T07:45:55.952Z', + GSI1PK: 'ALL_SITES', + createdAt: '2024-11-29T07:45:55.952Z', + updatedAt: '2024-11-29T07:45:55.952Z', + config: + { + slack: + { + workspace: '9-workspace', + channel: '9-channel', + }, + handlers: + { + 404: + { + byOrg: true, + mentions: + { + slack: + [ + '9-slackId', + ], + }, + }, + 'lhs-mobile': + { + excludedURLs: + [ + 'https://example.com/excluded', + ], + }, + }, + }, + }, +]; + +export default sites; diff --git a/packages/spacecat-shared-data-access/test/fixtures/suggestions.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/suggestions.fixture.js new file mode 100755 index 00000000..1152d97a --- /dev/null +++ b/packages/spacecat-shared-data-access/test/fixtures/suggestions.fixture.js @@ -0,0 +1,115 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const suggestions = [ + { + opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', + title: 'Suggestion 0 for Opportunity 0', + description: 'Description for Suggestion 0 of Opportunity 0', + data: { + foo: 'bar-0', + }, + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + }, + { + opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', + title: 'Suggestion 1 for Opportunity 0', + description: 'Description for Suggestion 1 of Opportunity 0', + data: { + foo: 'bar-1', + }, + type: 'CODE_CHANGE', + rank: 1, + status: 'SKIPPED', + }, + { + opportunityId: 'd27f4e5a-850c-441e-9c22-8e5e08b1e687', + title: 'Suggestion 2 for Opportunity 0', + description: 'Description for Suggestion 2 of Opportunity 0', + data: { + foo: 'bar-2', + }, + type: 'CODE_CHANGE', + rank: 2, + status: 'NEW', + }, + { + opportunityId: '742c49a7-d61f-4c62-9f7c-3207f520ed1e', + title: 'Suggestion 0 for Opportunity 1', + description: 'Description for Suggestion 0 of Opportunity 1', + data: { + foo: 'bar-0', + }, + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + }, + { + opportunityId: '742c49a7-d61f-4c62-9f7c-3207f520ed1e', + title: 'Suggestion 1 for Opportunity 1', + description: 'Description for Suggestion 1 of Opportunity 1', + data: { + foo: 'bar-1', + }, + type: 'CODE_CHANGE', + rank: 1, + status: 'NEW', + }, + { + opportunityId: '742c49a7-d61f-4c62-9f7c-3207f520ed1e', + title: 'Suggestion 2 for Opportunity 1', + description: 'Description for Suggestion 2 of Opportunity 1', + data: { + foo: 'bar-2', + }, + type: 'CODE_CHANGE', + rank: 2, + status: 'NEW', + }, + { + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + title: 'Suggestion 0 for Opportunity 2', + description: 'Description for Suggestion 0 of Opportunity 2', + data: { + foo: 'bar-0', + }, + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + }, + { + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + title: 'Suggestion 1 for Opportunity 2', + description: 'Description for Suggestion 1 of Opportunity 2', + data: { + foo: 'bar-1', + }, + type: 'CODE_CHANGE', + rank: 1, + status: 'NEW', + }, + { + opportunityId: 'aeeb4b8d-e771-47ef-99f4-ea4e349c81e4', + title: 'Suggestion 2 for Opportunity 2', + description: 'Description for Suggestion 2 of Opportunity 2', + data: { + foo: 'bar-2', + }, + type: 'CODE_CHANGE', + rank: 2, + status: 'NEW', + }, +]; + +export default suggestions; diff --git a/packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js b/packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js new file mode 100644 index 00000000..e2e3d537 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/api-key/api-key.test.js @@ -0,0 +1,137 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +describe('ApiKey IT', async () => { + let sampleData; + let ApiKey; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + ApiKey = dataAccess.ApiKey; + }); + + it('adds a new api key', async () => { + const data = { + name: 'Test API Key', + expiresAt: '2025-12-06T08:35:24.125Z', + hashedApiKey: '1234', + imsOrgId: '1234@AdobeOrg', + imsUserId: '1234', + scopes: [ + { name: 'imports.read' }, + { name: 'imports.write', domains: ['https://example.com'] }, + ], + }; + + const apiKey = await ApiKey.create(data); + + expect(apiKey).to.be.an('object'); + expect(apiKey.getId()).to.be.a('string'); + expect(apiKey.getCreatedAt()).to.be.a('string'); + expect(apiKey.getUpdatedAt()).to.be.a('string'); + + expect( + sanitizeIdAndAuditFields('ApiKey', apiKey.toJSON()), + ).to.eql(data); + }); + + it('gets all api keys by imsUserId and imsOrgId', async () => { + const sampleApiKey = sampleData.apiKeys[0]; + const apiKeys = await ApiKey.allByImsOrgIdAndImsUserId( + sampleApiKey.getImsOrgId(), + sampleApiKey.getImsUserId(), + ); + + expect(apiKeys).to.be.an('array'); + expect(apiKeys.length).to.equal(2); + + apiKeys.forEach((apiKey) => { + expect(apiKey.getImsOrgId()).to.equal(sampleApiKey.getImsOrgId()); + expect(apiKey.getImsUserId()).to.equal(sampleApiKey.getImsUserId()); + }); + }); + + it('finds an api key by hashedApiKey', async () => { + const sampleApiKey = sampleData.apiKeys[0]; + const apiKey = await ApiKey.findByHashedApiKey(sampleApiKey.getHashedApiKey()); + + expect(apiKey).to.be.an('object'); + + expect( + sanitizeTimestamps(apiKey.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleApiKey.toJSON()), + ); + }); + + it('finds an api key by its id', async () => { + const sampleApiKey = sampleData.apiKeys[0]; + const apiKey = await ApiKey.findById(sampleApiKey.getId()); + + expect(apiKey).to.be.an('object'); + + expect( + sanitizeTimestamps(apiKey.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleApiKey.toJSON()), + ); + }); + + it('updates an api key', async () => { + const apiKey = await ApiKey.findById(sampleData.apiKeys[0].getId()); + + const data = { + name: 'Updated API Key', + expiresAt: '2024-12-06T08:35:24.125Z', + hashedApiKey: '1234', + imsOrgId: '1234@AdobeOrg', + imsUserId: '1234', + scopes: [ + { name: 'imports.write' }, + { name: 'imports.read', domains: ['https://updated-example.com'] }, + ], + }; + + const result = await apiKey + .setName(data.name) + .setExpiresAt(data.expiresAt) + .setHashedApiKey(data.hashedApiKey) + .setImsOrgId(data.imsOrgId) + .setImsUserId(data.imsUserId) + .setScopes(data.scopes) + .save(); + + expect(result).to.be.an('object'); + + const updatedApiKey = await ApiKey.findById(sampleData.apiKeys[0].getId()); + + expect(updatedApiKey.getId()).to.equal(apiKey.getId()); + + expect( + sanitizeIdAndAuditFields('ApiKey', updatedApiKey.toJSON()), + ).to.eql(data); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/audit/audit.test.js b/packages/spacecat-shared-data-access/test/it/audit/audit.test.js new file mode 100644 index 00000000..0c613b23 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/audit/audit.test.js @@ -0,0 +1,100 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +function checkAudit(audit) { + expect(audit).to.be.an('object'); + expect(audit.getId()).to.be.a('string'); + expect(audit.getSiteId()).to.be.a('string'); + expect(audit.getAuditType()).to.be.a('string'); + expect(audit.getAuditedAt()).to.be.a('string'); + expect(audit.getAuditResult()).to.be.an('object'); + expect(audit.getScores()).to.be.an('object'); + expect(audit.getFullAuditRef()).to.be.a('string'); + expect(audit.getIsLive()).to.be.a('boolean'); +} + +describe('Audit IT', async () => { + let sampleData; + let Audit; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Audit = dataAccess.Audit; + }); + + it('gets all audits for a site', async () => { + const site = sampleData.sites[1]; + + const audits = await Audit.allBySiteId(site.getId()); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(10); + + audits.forEach((audit) => { + expect(audit.getSiteId()).to.equal(site.getId()); + checkAudit(audit); + }); + }); + + it('gets audits of type for a site', async () => { + const auditType = 'lhs-mobile'; + const site = sampleData.sites[1]; + + const audits = await Audit.allBySiteIdAndAuditType(site.getId(), auditType); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(5); + + audits.forEach((audit) => { + expect(audit.getSiteId()).to.equal(site.getId()); + expect(audit.getAuditType()).to.equal(auditType); + checkAudit(audit); + }); + }); + + it('gets latest audit of type lhs-mobile for a site', async () => { + const auditType = 'lhs-mobile'; + const site = sampleData.sites[1]; + const audits = await site.getAudits(); + const audit = await site.getLatestAuditByType(auditType); + + checkAudit(audit); + + expect(audit.getSiteId()).to.equal(site.getId()); + expect(audit.getAuditType()).to.equal(auditType); + expect(audit.getAuditedAt()).to.equal(audits[5].getAuditedAt()); + }); + + it('returns null for non-existing audit', async () => { + let audit = await Audit.findById('78fec9c7-2141-4600-b7b1-ea4c78752b91'); + + expect(audit).to.be.null; + + const site = sampleData.sites[1]; + audit = await site.getLatestAuditByType('non-existing-type'); + + expect(audit).to.be.null; + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js b/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js new file mode 100644 index 00000000..e334c8bb --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js @@ -0,0 +1,118 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +describe('Configuration IT', async () => { + let sampleData; + let Configuration; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Configuration = dataAccess.Configuration; + }); + + it('gets all configurations', async () => { + const configurations = await Configuration.all(); + + expect(configurations).to.be.an('array'); + expect(configurations).to.have.lengthOf(sampleData.configurations.length); + configurations.forEach((configuration, index) => { + expect( + sanitizeTimestamps(configuration.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.configurations[index].toJSON()), + ); + }); + }); + + it('finds one configuration by version', async () => { + const sampleConfiguration = sampleData.configurations[1]; + const configuration = await Configuration.findByVersion( + sampleConfiguration.getVersion(), + ); + + expect(configuration).to.be.an('object'); + expect( + sanitizeTimestamps(configuration.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleConfiguration.toJSON()), + ); + }); + + it('finds the latest configuration', async () => { + const sampleConfiguration = sampleData.configurations[0]; + const configuration = await Configuration.findLatest(); + + expect(configuration).to.be.an('object'); + expect( + sanitizeTimestamps(configuration.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleConfiguration.toJSON()), + ); + }); + + it('updates a configuration', async () => { + const configuration = await Configuration.findLatest(); + + const data = { + enabledByDefault: true, + enabled: { + sites: ['site1'], + orgs: ['org1'], + }, + }; + + const expectedConfiguration = { + ...configuration.toJSON(), + handlers: { + ...configuration.toJSON().handlers, + test: data, + }, + version: configuration.getVersion() + 1, + }; + + configuration.addHandler('test', data); + + await configuration.save(); + + const updatedConfiguration = await Configuration.findLatest(); + expect(updatedConfiguration.getId()).to.not.equal(configuration.getId()); + expect( + Date.parse(updatedConfiguration.record.createdAt), + ).to.be.greaterThan( + Date.parse(configuration.record.createdAt), + ); + expect( + Date.parse(updatedConfiguration.record.updatedAt), + ).to.be.greaterThan( + Date.parse(configuration.record.updatedAt), + ); + expect( + sanitizeIdAndAuditFields('Configuration', updatedConfiguration.toJSON()), + ).to.eql( + sanitizeIdAndAuditFields('Configuration', expectedConfiguration), + ); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js b/packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js new file mode 100644 index 00000000..d66dc962 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/experiment/experiment.test.js @@ -0,0 +1,167 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeIdAndAuditFields } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +function checkExperiment(experiment) { + expect(experiment).to.be.an('object'); + expect(experiment.getId()).to.be.a('string'); + expect(experiment.getCreatedAt()).to.be.a('string'); + expect(experiment.getUpdatedAt()).to.be.a('string'); + expect(experiment.getEndDate()).to.be.a('string'); + expect(experiment.getExpId()).to.be.a('string'); + expect(experiment.getName()).to.be.a('string'); + expect(experiment.getStatus()).to.be.a('string'); + expect(experiment.getStartDate()).to.be.a('string'); + expect(experiment.getType()).to.be.a('string'); + expect(experiment.getUrl()).to.be.a('string'); + expect(experiment.getVariants()).to.be.an('array'); +} + +describe('Experiment IT', async () => { + let sampleData; + let Experiment; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Experiment = dataAccess.Experiment; + }); + + it('gets all experiments for a site', async () => { + const site = sampleData.sites[0]; + + const experiments = await Experiment.allBySiteId(site.getId()); + + expect(experiments).to.be.an('array'); + expect(experiments.length).to.equal(3); + + experiments.forEach((experiment) => { + expect(experiment.getSiteId()).to.equal(site.getId()); + checkExperiment(experiment); + }); + }); + + it('gets all experiments for a site and expId', async () => { + const site = sampleData.sites[0]; + const expId = 'experiment-1'; + + const experiments = await Experiment.allBySiteIdAndExpId(site.getId(), expId); + + expect(experiments).to.be.an('array'); + expect(experiments.length).to.equal(1); + + const experiment = experiments[0]; + expect(experiment.getSiteId()).to.equal(site.getId()); + checkExperiment(experiment); + }); + + it('returns empty array for a site with no experiments', async () => { + const site = sampleData.sites[1]; + + const experiments = await Experiment.allBySiteId(site.getId()); + + expect(experiments).to.be.an('array'); + expect(experiments.length).to.equal(0); + }); + + it('finds one experiment by siteId, expId and url', async () => { + const site = sampleData.sites[0]; + const expId = 'experiment-1'; + const url = 'https://example0.com/page-1'; + + const experiment = await Experiment.findBySiteIdAndExpId(site.getId(), expId, url); + + checkExperiment(experiment); + expect(experiment.getUrl()).to.equal(url); + }); + + it('adds a new experiment to a site', async () => { + const site = sampleData.sites[0]; + const experimentData = { + siteId: site.getId(), + expId: 'experiment-4', + name: 'Experiment 4', + url: 'https://example0.com/page-4', + status: 'ACTIVE', + type: 'full', + startDate: '2024-12-06T08:35:24.125Z', + endDate: '2025-12-06T08:35:24.125Z', + variants: [ + { + label: 'Challenger 1', + name: 'challenger-1', + interactionsCount: 10, + p_value: 'coming soon', + split: 0.8, + url: 'https://example0.com/page-4/variant-1', + views: 100, + metrics: [ + { + selector: '.header .button', + type: 'click', + value: 2, + }, + ], + }, + { + label: 'Challenger 2', + name: 'challenger-2', + interactionsCount: 20, + p_value: 'coming soon', + metrics: [], + split: 0.8, + url: 'https://example0.com/page-4/variant-2', + views: 200, + }, + ], + updatedBy: 'scheduled-experiment-audit', + }; + + const addedExperiment = await Experiment.create(experimentData); + + checkExperiment(addedExperiment); + + expect(sanitizeIdAndAuditFields('Experiment', addedExperiment.toJSON())).to.eql(experimentData); + }); + + it('updates an existing experiment', async () => { + const site = sampleData.sites[0]; + const expId = 'experiment-1'; + const url = 'https://example0.com/page-1'; + const updates = { + name: 'Updated Experiment 1', + url: 'https://example0.com/page-1/updated', + }; + + const experiment = await Experiment.findBySiteIdAndExpIdAndUrl(site.getId(), expId, url); + experiment.setName(updates.name); + experiment.setUrl(updates.url); + + await experiment.save(); + + checkExperiment(experiment); + expect(experiment.getName()).to.equal(updates.name); + expect(experiment.getUrl()).to.equal(updates.url); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/fixtures.js b/packages/spacecat-shared-data-access/test/it/fixtures.js new file mode 100644 index 00000000..34e50988 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/fixtures.js @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-disable no-console */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { spawn } from 'dynamo-db-local'; + +import { sleep } from '../unit/util.js'; + +let dynamoDbLocalProcess = null; + +async function waitForDynamoDBStartup(url, timeout = 20000, interval = 500) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await fetch(url); + if (response.status === 400) { + return; + } + } catch (error) { + // eslint-disable-next-line no-console + console.log('DynamoDB Local not yet started', error.message); + } + // eslint-disable-next-line no-await-in-loop + await sleep(interval); + } + throw new Error('DynamoDB Local did not start within the expected time'); +} + +/** + * This function is called once before any tests are executed. It is used to start + * any services that are required for the tests, such as a local DynamoDB instance. + * See https://mochajs.org/#global-fixtures + * @return {Promise} + */ +export async function mochaGlobalSetup() { + console.log('mochaGlobalSetup'); + + process.env.AWS_REGION = 'local'; + process.env.AWS_ENDPOINT_URL_DYNAMODB = 'http://127.0.0.1:8000'; + process.env.AWS_DEFAULT_REGION = 'local'; + process.env.AWS_ACCESS_KEY_ID = 'dummy'; + process.env.AWS_SECRET_ACCESS_KEY = 'dummy'; + + dynamoDbLocalProcess = spawn({ + detached: true, + stdio: 'inherit', + port: 8000, + sharedDb: true, + }); + + await waitForDynamoDBStartup('http://127.0.0.1:8000'); + + process.on('SIGINT', () => { + if (dynamoDbLocalProcess) { + dynamoDbLocalProcess.kill(); + } + process.exit(); + }); +} + +/** + * This function is called once after all tests are executed. It is used to clean up + * any services that were started in mochaGlobalSetup. + * See: https://mochajs.org/#global-fixtures + * @return {Promise} + */ +export async function mochaGlobalTeardown() { + console.log('mochaGlobalTeardown'); + + dynamoDbLocalProcess.kill(); + dynamoDbLocalProcess = null; +} diff --git a/packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js b/packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js new file mode 100644 index 00000000..d6d2439e --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/import-job/import-job.test.js @@ -0,0 +1,165 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +function checkImportJob(importJob) { + expect(importJob).to.be.an('object'); + expect(importJob.getBaseURL()).to.be.a('string'); + expect(importJob.getDuration()).to.be.a('number'); + expect(importJob.getFailedCount()).to.be.a('number'); + expect(importJob.getHasCustomHeaders()).to.be.a('boolean'); + expect(importJob.getHasCustomImportJs()).to.be.a('boolean'); + expect(importJob.getHashedApiKey()).to.be.a('string'); + expect(importJob.getImportQueueId()).to.be.a('string'); + expect(importJob.getInitiatedBy()).to.be.an('object'); + expect(importJob.getRedirectCount()).to.be.an('number'); + expect(importJob.getStartedAt()).to.be.a('string'); + expect(importJob.getStatus()).to.be.a('string'); + expect(importJob.getSuccessCount()).to.be.an('number'); + expect(importJob.getUrlCount()).to.be.an('number'); +} + +describe('ImportJob IT', async () => { + let sampleData; + let ImportJob; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + ImportJob = dataAccess.ImportJob; + }); + + it('adds a new import job', async () => { + const data = { + importQueueId: 'some-queue-id', + hashedApiKey: 'some-hashed-api-key', + baseURL: 'https://example-some.com/cars', + startedAt: '2023-12-15T01:22:05.000Z', + status: 'RUNNING', + initiatedBy: { + apiKeyName: 'K-321', + }, + hasCustomImportJs: false, + hasCustomHeaders: true, + }; + const importJob = await ImportJob.create(data); + + checkImportJob(importJob); + + expect(importJob.getImportQueueId()).to.equal(data.importQueueId); + expect(importJob.getHashedApiKey()).to.equal(data.hashedApiKey); + expect(importJob.getBaseURL()).to.equal(data.baseURL); + expect(importJob.getStartedAt()).to.equal(data.startedAt); + expect(importJob.getStatus()).to.equal(data.status); + expect(importJob.getInitiatedBy()).to.eql(data.initiatedBy); + expect(importJob.getHasCustomImportJs()).to.equal(data.hasCustomImportJs); + expect(importJob.getHasCustomHeaders()).to.equal(data.hasCustomHeaders); + }); + + it('updates an existing import job', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const importJob = await ImportJob.findById(sampleImportJob.getId()); + + const updates = { + status: 'COMPLETE', + endedAt: '2023-11-15T03:49:13.000Z', + successCount: 86, + failedCount: 4, + redirectCount: 10, + urlCount: 100, + duration: 188000, + }; + + await importJob + .setStatus(updates.status) + .setEndedAt(updates.endedAt) + .setSuccessCount(updates.successCount) + .setFailedCount(updates.failedCount) + .setRedirectCount(updates.redirectCount) + .setUrlCount(updates.urlCount) + .setDuration(updates.duration) + .save(); + + const updatedImportJob = await ImportJob.findById(importJob.getId()); + + checkImportJob(updatedImportJob); + + expect(updatedImportJob.getStatus()).to.equal(updates.status); + expect(updatedImportJob.getEndedAt()).to.equal(updates.endedAt); + expect(updatedImportJob.getSuccessCount()).to.equal(updates.successCount); + expect(updatedImportJob.getFailedCount()).to.equal(updates.failedCount); + expect(updatedImportJob.getRedirectCount()).to.equal(updates.redirectCount); + expect(updatedImportJob.getUrlCount()).to.equal(updates.urlCount); + expect(updatedImportJob.getDuration()).to.equal(updates.duration); + }); + + it('finds an import job by its id', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const importJob = await ImportJob.findById(sampleImportJob.getId()); + + checkImportJob(importJob); + expect(importJob.getId()).to.equal(sampleImportJob.getId()); + }); + + it('gets all import jobs by status', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const importJobs = await ImportJob.allByStatus(sampleImportJob.getStatus()); + + expect(importJobs).to.be.an('array'); + importJobs.forEach((importJob) => { + checkImportJob(importJob); + expect(importJob.getStatus()).to.equal(sampleImportJob.getStatus()); + }); + }); + + it('gets all import jobs by date range', async () => { + const importJobs = await ImportJob.allByDateRange( + '2023-11-14T00:00:00.000Z', + '2023-11-16T00:00:00.000Z', + ); + + expect(importJobs).to.be.an('array'); + expect(importJobs.length).to.equal(2); + + importJobs.forEach((importJob) => { + checkImportJob(importJob); + }); + }); + + it('removes an import job', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const importJob = await ImportJob.findById(sampleImportJob.getId()); + + const importUrls = await importJob.getImportUrls(); + + expect(importUrls).to.be.an('array'); + expect(importUrls.length).to.equal(5); + + await importJob.remove(); + + const removedImportJob = await ImportJob.findById(sampleImportJob.getId()); + expect(removedImportJob).to.be.null; + + // todo: verify import urls are removed when base collection is implemented + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js b/packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js new file mode 100644 index 00000000..7a9a2a73 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/import-url/import-url.test.js @@ -0,0 +1,131 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +function checkImportUrl(importUrl) { + expect(importUrl).to.be.an('object'); + expect(importUrl.getExpiresAt()).to.be.a('string'); + expect(importUrl.getImportJobId()).to.be.a('string'); + expect(importUrl.getStatus()).to.be.a('string'); + expect(importUrl.getUrl()).to.be.a('string'); +} + +describe('ImportUrl IT', async () => { + let sampleData; + let ImportUrl; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + ImportUrl = dataAccess.ImportUrl; + }); + + it('adds a new import url', async () => { + const sampleImportJob = sampleData.importJobs[0]; + const data = { + importJobId: sampleImportJob.getId(), + url: 'https://example-some.com/cars', + status: 'RUNNING', + initiatedBy: { + apiKeyName: 'K-321', + imsUserId: 'U-123', + imsOrgId: 'O-123', + }, + }; + + const importUrl = await ImportUrl.create(data); + + checkImportUrl(importUrl); + }); + + it('updates an import url', async () => { + const data = { + url: 'https://example-some.com/cars', + status: 'RUNNING', + file: 'some-file', + reason: 'some-reason', + }; + + const importUrl = await ImportUrl.findById(sampleData.importUrls[0].getId()); + await importUrl + .setUrl(data.url) + .setStatus(data.status) + .setFile(data.file) + .setReason(data.reason) + .save(); + + const updatedImportUrl = await ImportUrl.findById(sampleData.importUrls[0].getId()); + + checkImportUrl(updatedImportUrl); + + expect(updatedImportUrl.getStatus()).to.equal(data.status); + expect(updatedImportUrl.getUrl()).to.equal(data.url); + expect(updatedImportUrl.getFile()).to.equal(data.file); + expect(updatedImportUrl.getReason()).to.equal(data.reason); + }); + + it('it gets all import urls by import job id', async () => { + const importJob = sampleData.importJobs[0]; + const importUrls = await ImportUrl.allByImportJobId(importJob.getId()); + + expect(importUrls).to.be.an('array'); + expect(importUrls.length).to.equal(6); + + importUrls.forEach((importUrl) => { + expect(importUrl.getImportJobId()).to.equal(importJob.getId()); + checkImportUrl(importUrl); + }); + }); + + it('it gets all import urls by job id and status', async () => { + const importJob = sampleData.importJobs[0]; + const importUrls = await ImportUrl.allByImportJobIdAndStatus(importJob.getId(), 'RUNNING'); + + expect(importUrls).to.be.an('array'); + expect(importUrls.length).to.equal(2); + + importUrls.forEach((importUrl) => { + expect(importUrl.getImportJobId()).to.equal(importJob.getId()); + expect(importUrl.getStatus()).to.equal('RUNNING'); + checkImportUrl(importUrl); + }); + }); + + it('finds an import url by its id', async () => { + const sampleImportUrl = sampleData.importUrls[0]; + const importUrl = await ImportUrl.findById(sampleImportUrl.getId()); + + checkImportUrl(importUrl); + expect(importUrl.getId()).to.equal(sampleImportUrl.getId()); + }); + + it('removes an import url', async () => { + const sampleImportUrl = sampleData.importUrls[0]; + const importUrl = await ImportUrl.findById(sampleImportUrl.getId()); + + await importUrl.remove(); + + const removedImportUrl = await ImportUrl.findById(sampleImportUrl.getId()); + expect(removedImportUrl).to.be.null; + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/index.test.js b/packages/spacecat-shared-data-access/test/it/index.test.js old mode 100644 new mode 100755 index 09d2ca4a..43fefb3d --- a/packages/spacecat-shared-data-access/test/it/index.test.js +++ b/packages/spacecat-shared-data-access/test/it/index.test.js @@ -13,18 +13,17 @@ /* eslint-env mocha */ /* eslint-disable no-console */ +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import Joi from 'joi'; - -import { isIsoDate } from '@adobe/spacecat-shared-utils'; import { v4 as uuidv4 } from 'uuid'; -import { sleep } from '../unit/util.js'; -import { createDataAccess } from '../../src/service/index.js'; + import { configSchema } from '../../src/models/site/config.js'; import { AUDIT_TYPE_LHS_MOBILE } from '../../src/models/audit.js'; +import { sleep } from '../unit/util.js'; -import generateSampleData from './util/generateSampleData.js'; import { createSiteCandidate, SITE_CANDIDATE_SOURCES, @@ -34,7 +33,8 @@ import { KEY_EVENT_TYPES } from '../../src/models/key-event.js'; import { ConfigurationDto } from '../../src/dto/configuration.js'; import { ImportJobStatus, ImportOptions, ImportUrlStatus } from '../../src/index.js'; import { IMPORT_URL_EXPIRES_IN_DAYS } from '../../src/models/importer/import-url.js'; -import { closeDynamoClients } from './util/db.js'; +import { getDataAccess } from './util/db.js'; +import { seedDatabase } from './util/seed.js'; use(chaiAsPromised); @@ -96,74 +96,20 @@ function checkSiteTopPage(siteTopPage) { expect(isIsoDate(siteTopPage.getImportedAt())).to.be.true; } -export const TEST_DA_CONFIG = { - tableNameAudits: 'spacecat-services-audits', - tableNameKeyEvents: 'spacecat-services-key-events', - tableNameLatestAudits: 'spacecat-services-latest-audits', - tableNameOrganizations: 'spacecat-services-organizations', - tableNameSites: 'spacecat-services-sites', - tableNameSiteCandidates: 'spacecat-services-site-candidates', - tableNameConfigurations: 'spacecat-services-configurations', - tableNameSiteTopPages: 'spacecat-services-site-top-pages', - tableNameExperiments: 'spacecat-services-experiments', - tableNameApiKeys: 'spacecat-services-api-keys', - tableNameImportJobs: 'spacecat-services-import-jobs', - tableNameImportUrls: 'spacecat-services-import-urls', - tableNameSpacecatData: 'spacecat-data', - indexNameAllSites: 'spacecat-services-all-sites', - indexNameAllKeyEventsBySiteId: 'spacecat-services-key-events-by-site-id', - indexNameAllSitesOrganizations: 'spacecat-services-all-sites-organizations', - indexNameAllOrganizations: 'spacecat-services-all-organizations', - indexNameAllOrganizationsByImsOrgId: 'spacecat-services-all-organizations-by-ims-org-id', - indexNameAllSitesByDeliveryType: 'spacecat-services-all-sites-by-delivery-type', - indexNameAllLatestAuditScores: 'spacecat-services-all-latest-audit-scores', - indexNameAllImportJobsByStatus: 'spacecat-services-all-import-jobs-by-status', - indexNameImportUrlsByJobIdAndStatus: 'spacecat-services-all-import-urls-by-job-id-and-status', - indexNameAllImportJobsByDateRange: 'spacecat-services-all-import-jobs-by-date-range', - indexNameApiKeyByHashedApiKey: 'spacecat-services-api-key-by-hashed-api-key', - indexNameApiKeyByImsUserIdAndImsOrgId: 'spacecat-services-api-key-by-ims-user-id-and-ims-org-id', - pkAllSites: 'ALL_SITES', - pkAllOrganizations: 'ALL_ORGANIZATIONS', - pkAllLatestAudits: 'ALL_LATEST_AUDITS', - pkAllConfigurations: 'ALL_CONFIGURATIONS', - pkAllImportJobs: 'ALL_IMPORT_JOBS', -}; +const NUMBER_OF_SITES = 10; +const NUMBER_OF_ORGANIZATIONS = 3; +const NUMBER_OF_AUDITS_PER_TYPE_AND_SITE = 5; +const NUMBER_OF_TOP_PAGES_PER_SITE = 5; +const NUMBER_OF_KEY_EVENTS_PER_SITE = 10; +const NUMBER_OF_EXPERIMENTS = 3; // eslint-disable-next-line func-names -describe('Legacy Data Model IT', function () { - this.timeout(30000); - +describe('Legacy Data Model IT', async () => { let dataAccess; - const NUMBER_OF_SITES = 10; - const NUMBER_OF_SITES_CANDIDATES = 10; - const NUMBER_OF_ORGANIZATIONS = 3; - const NUMBER_OF_AUDITS_PER_TYPE_AND_SITE = 3; - const NUMBER_OF_TOP_PAGES_PER_SITE = 5; - const NUMBER_OF_TOP_PAGES_FOR_SITE = NUMBER_OF_SITES * NUMBER_OF_TOP_PAGES_PER_SITE; - const NUMBER_OF_KEY_EVENTS_PER_SITE = 10; - const NUMBER_OF_EXPERIMENTS = 3; - before(async () => { - try { - await generateSampleData( - TEST_DA_CONFIG, - NUMBER_OF_ORGANIZATIONS, - NUMBER_OF_SITES, - NUMBER_OF_SITES_CANDIDATES, - NUMBER_OF_AUDITS_PER_TYPE_AND_SITE, - NUMBER_OF_TOP_PAGES_FOR_SITE, - NUMBER_OF_KEY_EVENTS_PER_SITE, - ); - } catch (e) { - console.error('Error generating sample data', e); - } - - dataAccess = createDataAccess(TEST_DA_CONFIG, console); - }); - - after(async () => { - await closeDynamoClients(); + await seedDatabase(); + dataAccess = getDataAccess(); }); it('get all key events for a site', async () => { diff --git a/packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js b/packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js new file mode 100644 index 00000000..db1180b0 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/key-events/key-events.test.js @@ -0,0 +1,99 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +function checkKeyEvent(keyEvent) { + expect(keyEvent).to.be.an('object'); + expect(keyEvent.getId()).to.be.a('string'); + expect(keyEvent.getCreatedAt()).to.be.a('string'); + expect(keyEvent.getUpdatedAt()).to.be.a('string'); + expect(keyEvent.getSiteId()).to.be.a('string'); + expect(keyEvent.getName()).to.be.a('string'); + expect(keyEvent.getType()).to.be.a('string'); + expect(keyEvent.getTime()).to.be.a('string'); +} + +describe('KeyEvent IT', async () => { + let sampleData; + let KeyEvent; + let Site; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + KeyEvent = dataAccess.KeyEvent; + Site = dataAccess.Site; + }); + + it('gets all key events for a site', async () => { + const site = sampleData.sites[1]; + + const keyEvents = await KeyEvent.allBySiteId(site.getId()); + + expect(keyEvents).to.be.an('array'); + expect(keyEvents.length).to.equal(10); + + keyEvents.forEach((keyEvent) => { + expect(keyEvent.getSiteId()).to.equal(site.getId()); + checkKeyEvent(keyEvent); + }); + }); + + it('adds a new key event for a site', async () => { + const site = sampleData.sites[1]; + const keyEvent = await KeyEvent.create({ + siteId: site.getId(), + name: 'keyEventName', + type: 'PERFORMANCE', + time: '2024-12-06T08:35:24.125Z', + }); + + checkKeyEvent(keyEvent); + + expect(keyEvent.getSiteId()).to.equal(site.getId()); + + const siteWithKeyEvent = await Site.findById(site.getId()); + + const keyEvents = await siteWithKeyEvent.getKeyEvents(); + expect(keyEvents).to.be.an('array'); + expect(keyEvents.length).to.equal(11); + + const lastKeyEvent = keyEvents[0]; + checkKeyEvent(lastKeyEvent); + expect(lastKeyEvent.getId()).to.equal(keyEvent.getId()); + }); + + it('removes a key event', async () => { + const site = sampleData.sites[1]; + const keyEvents = await site.getKeyEvents(); + const keyEvent = keyEvents[0]; + + await keyEvent.remove(); + + const siteWithKeyEvent = await Site.findById(site.getId()); + + const updatedKeyEvents = await siteWithKeyEvent.getKeyEvents(); + expect(updatedKeyEvents).to.be.an('array'); + expect(updatedKeyEvents.length).to.equal(keyEvents.length - 1); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js new file mode 100644 index 00000000..2a50d998 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js @@ -0,0 +1,320 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { v4 as uuid, validate as uuidValidate } from 'uuid'; + +import { ValidationError } from '../../../src/index.js'; + +import fixtures from '../../fixtures/index.fixtures.js'; +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +describe('Opportunity IT', async () => { + const { siteId } = fixtures.sites[0]; + + let sampleData; + + let Opportunity; + let Suggestion; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Opportunity = dataAccess.Opportunity; + Suggestion = dataAccess.Suggestion; + }); + + it('finds one opportunity by id', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); + + expect(opportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.opportunities[0].toJSON()), + ); + + const suggestions = await opportunity.getSuggestions(); + expect(suggestions).to.be.an('array').with.length(3); + + const parentOpportunity = await suggestions[0].getOpportunity(); + expect(parentOpportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.opportunities[0].toJSON()), + ); + }); + + it('finds all opportunities by siteId and status', async () => { + const opportunities = await Opportunity.allBySiteIdAndStatus(siteId, 'NEW'); + + expect(opportunities).to.be.an('array').with.length(2); + }); + + it('partially updates one opportunity by id', async () => { + // retrieve the opportunity by ID + const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); + expect(opportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.opportunities[0].toJSON()), + ); + + // apply updates + const updates = { + runbook: 'https://example-updated.com', + status: 'IN_PROGRESS', + }; + + opportunity + .setRunbook(updates.runbook) + .setStatus(updates.status); + + // opportunity.setAuditId('invalid-audit-id'); + + await opportunity.save(); + + expect(opportunity.getRunbook()).to.equal(updates.runbook); + expect(opportunity.getStatus()).to.equal(updates.status); + + const updated = sanitizeTimestamps(opportunity.toJSON()); + delete updated.runbook; + delete updated.status; + + const original = sanitizeTimestamps(sampleData.opportunities[0].toJSON()); + delete original.runbook; + delete original.status; + + expect(updated).to.eql(original); + + const storedOpportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); + expect(storedOpportunity.getRunbook()).to.equal(updates.runbook); + expect(storedOpportunity.getStatus()).to.equal(updates.status); + + const storedWithoutUpdatedAt = { ...storedOpportunity.toJSON() }; + const inMemoryWithoutUpdatedAt = { ...opportunity.toJSON() }; + delete storedWithoutUpdatedAt.updatedAt; + delete inMemoryWithoutUpdatedAt.updatedAt; + + expect(storedWithoutUpdatedAt).to.eql(inMemoryWithoutUpdatedAt); + }); + + it('finds all opportunities by siteId', async () => { + const opportunities = await Opportunity.allBySiteId(siteId); + + expect(opportunities).to.be.an('array').with.length(3); + }); + + it('creates a new opportunity', async () => { + const data = { + siteId, + auditId: uuid(), + title: 'New Opportunity', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-backlinks', + origin: 'AI', + status: 'NEW', + guidance: { foo: 'bar' }, + data: { brokenLinks: ['https://example.com'] }, + }; + + const opportunity = await Opportunity.create(data); + + expect(opportunity).to.be.an('object'); + + expect(uuidValidate(opportunity.getId())).to.be.true; + expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; + expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; + + const record = opportunity.toJSON(); + delete record.opportunityId; + delete record.createdAt; + delete record.updatedAt; + expect(record).to.eql(data); + }); + + it('creates a new opportunity without auditId', async () => { + const data = { + siteId, + title: 'New Opportunity', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-backlinks', + origin: 'AI', + status: 'NEW', + guidance: { foo: 'bar' }, + data: { brokenLinks: ['https://example.com'] }, + }; + + const opportunity = await Opportunity.create(data); + + expect(opportunity).to.be.an('object'); + + expect(uuidValidate(opportunity.getId())).to.be.true; + expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; + expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; + + const record = opportunity.toJSON(); + delete record.opportunityId; + delete record.createdAt; + delete record.updatedAt; + expect(record).to.eql(data); + + expect(opportunity.getAuditId()).to.be.undefined; + await expect(opportunity.getAudit()).to.eventually.be.equal(null); + }); + + it('removes an opportunity', async () => { + const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); + const suggestions = await opportunity.getSuggestions(); + + expect(suggestions).to.be.an('array').with.length(3); + + await opportunity.remove(); + + const notFound = await Opportunity.findById(sampleData.opportunities[0].getId()); + await expect(notFound).to.be.null; + + // make sure dependent suggestions are removed as well + await Promise.all(suggestions.map(async (suggestion) => { + const notFoundSuggestion = await Suggestion.findById(suggestion.getId()); + await expect(notFoundSuggestion).to.be.null; + })); + }); + + it('creates many opportunities', async () => { + const data = [ + { + siteId, + auditId: uuid(), + title: 'New Opportunity 1', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-backlinks', + origin: 'AI', + status: 'NEW', + data: { brokenLinks: ['https://example.com'] }, + }, + { + siteId, + auditId: uuid(), + title: 'New Opportunity 2', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-internal-links', + origin: 'AI', + status: 'NEW', + data: { brokenInternalLinks: ['https://example.com'] }, + }, + ]; + + const opportunities = await Opportunity.createMany(data); + + expect(opportunities).to.be.an('object'); + expect(opportunities.createdItems).to.be.an('array').with.length(2); + expect(opportunities.errorItems).to.be.an('array').with.length(0); + + opportunities.createdItems.forEach((opportunity, index) => { + expect(opportunity).to.be.an('object'); + + expect(uuidValidate(opportunity.getId())).to.be.true; + expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; + expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; + + expect( + sanitizeIdAndAuditFields('Opportunity', opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(data[index]), + ); + }); + }); + + it('fails to create many opportunities with invalid data', async () => { + const data = [ + { + siteId, + auditId: uuid(), + title: 'New Opportunity 1', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-backlinks', + origin: 'AI', + status: 'NEW', + data: { brokenLinks: ['https://example.com'] }, + }, + { + siteId, + auditId: uuid(), + title: 'New Opportunity 2', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-internal-links', + origin: 'AI', + status: 'NEW', + data: { brokenInternalLinks: ['https://example.com'] }, + }, + { + siteId, + auditId: uuid(), + title: 'New Opportunity 3', + description: 'Description', + runbook: 'https://example.com', + type: 'broken-internal-links', + origin: 'AI', + status: 'NEW', + data: { brokenInternalLinks: ['https://example.com'] }, + }, + ]; + + data[2].title = null; + + const result = await Opportunity.createMany(data); + + expect(result).to.be.an('object'); + expect(result).to.have.property('createdItems'); + expect(result).to.have.property('errorItems'); + + expect(result.createdItems).to.be.an('array').with.length(2); + expect(result.errorItems).to.be.an('array').with.length(1); + expect(result.errorItems[0].item).to.eql(data[2]); + expect(result.errorItems[0].error).to.be.an.instanceOf(ValidationError); + + const [opportunity1, opportunity2] = result.createdItems; + + const record1 = opportunity1.toJSON(); + delete record1.opportunityId; + delete record1.createdAt; + delete record1.updatedAt; + + const record2 = opportunity2.toJSON(); + delete record2.opportunityId; + delete record2.createdAt; + delete record2.updatedAt; + + expect(record1).to.eql(data[0]); + expect(record2).to.eql(data[1]); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/organization/organization.test.js b/packages/spacecat-shared-data-access/test/it/organization/organization.test.js new file mode 100644 index 00000000..290d5abb --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/organization/organization.test.js @@ -0,0 +1,143 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +describe('Organization IT', async () => { + let sampleData; + let Organization; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Organization = dataAccess.Organization; + }); + + it('gets all organizations', async () => { + const organizations = await Organization.all(); + organizations.reverse(); // sort key is descending by default + + expect(organizations).to.be.an('array'); + expect(organizations.length).to.equal(sampleData.organizations.length); + for (let i = 0; i < organizations.length; i += 1) { + const org = sanitizeTimestamps(organizations[i].toJSON()); + const sampleOrg = sanitizeTimestamps(sampleData.organizations[i].toJSON()); + + expect(org).to.eql(sampleOrg); + } + }); + + it('gets an organization by id', async () => { + const sampleOrganization = sampleData.organizations[0]; + const organization = await Organization.findById(sampleOrganization.getId()); + + expect(organization).to.be.an('object'); + expect( + sanitizeTimestamps(organization.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleOrganization.toJSON()), + ); + }); + + it('gets an organization by IMS org id', async () => { + const sampleOrganization = sampleData.organizations[0]; + const organization = await Organization.findByImsOrgId(sampleOrganization.getImsOrgId()); + + expect(organization).to.be.an('object'); + expect( + sanitizeTimestamps(organization.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleOrganization.toJSON()), + ); + }); + + it('adds a new organization', async () => { + const data = { + name: 'New Organization', + imsOrgId: 'newOrgId', + config: { + some: 'config', + }, + fulfillableItems: { + some: 'items', + }, + }; + + const organization = await Organization.create(data); + + expect(organization).to.be.an('object'); + + expect( + sanitizeIdAndAuditFields('Organization', organization.toJSON()), + ).to.eql(data); + }); + + it('updates an organization', async () => { + const organization = await Organization.findById(sampleData.organizations[0].getId()); + + const data = { + name: 'Updated Organization', + imsOrgId: 'updatedOrgId', + config: { + some: 'updated', + }, + fulfillableItems: { + some: 'updated', + }, + }; + + const expectedOrganization = { + ...organization.toJSON(), + ...data, + }; + + organization.setName(data.name); + organization.setImsOrgId(data.imsOrgId); + organization.setConfig(data.config); + organization.setFulfillableItems(data.fulfillableItems); + + await organization.save(); + + const updatedOrganization = await Organization.findById(organization.getId()); + expect(updatedOrganization.getId()).to.equal(organization.getId()); + expect(updatedOrganization.record.createdAt).to.equal(organization.record.createdAt); + expect(updatedOrganization.record.updatedAt).to.not.equal(organization.record.updatedAt); + expect( + sanitizeIdAndAuditFields('Organization', updatedOrganization.toJSON()), + ).to.eql( + sanitizeIdAndAuditFields('Organization', expectedOrganization), + ); + }); + + it('removes an organization', async () => { + const organization = await Organization.findById(sampleData.organizations[0].getId()); + + await organization.remove(); + + const notFound = await Organization.findById(sampleData.organizations[0].getId()); + expect(notFound).to.be.null; + + // todo: add test for removing an organization with associated sites once + // that functionality is implemented + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/site-candidate/site-candidate.test.js b/packages/spacecat-shared-data-access/test/it/site-candidate/site-candidate.test.js new file mode 100644 index 00000000..bc137520 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/site-candidate/site-candidate.test.js @@ -0,0 +1,105 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +function checkSiteCandidate(siteCandidate) { + expect(siteCandidate).to.be.an('object'); + expect(siteCandidate.getBaseURL()).to.be.a('string'); + expect(siteCandidate.getCreatedAt()).to.be.a('string'); + expect(siteCandidate.getSource()).to.be.a('string'); + expect(siteCandidate.getStatus()).to.be.a('string'); + expect(siteCandidate.getUpdatedAt()).to.be.a('string'); +} + +describe('SiteCandidate IT', async () => { + let sampleData; + let SiteCandidate; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + SiteCandidate = dataAccess.SiteCandidate; + }); + + it('finds one site candidate by base url', async () => { + const sampleSiteCandidate = sampleData.siteCandidates[6]; + + const siteCandidate = await SiteCandidate.findByBaseURL(sampleSiteCandidate.getBaseURL()); + + checkSiteCandidate(siteCandidate); + + expect( + sanitizeTimestamps(siteCandidate.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleSiteCandidate.toJSON()), + ); + }); + + it('returns null when site candidate is not found by base url', async () => { + const siteCandidate = await SiteCandidate.findByBaseURL('https://www.example.com'); + + expect(siteCandidate).to.be.null; + }); + + it('adds a new site candidate', async () => { + const data = { + baseURL: 'https://www.example.com', + source: 'RUM', + status: 'PENDING', + }; + const siteCandidate = await SiteCandidate.create(data); + + checkSiteCandidate(siteCandidate); + + expect(siteCandidate.getBaseURL()).to.equal(data.baseURL); + expect(siteCandidate.getSource()).to.equal(data.source); + expect(siteCandidate.getStatus()).to.equal(data.status); + }); + + it('updates a site candidate', async () => { + const sampleSiteCandidate = sampleData.siteCandidates[0]; + const updates = { + baseURL: 'https://www.example-updated.com', + status: 'APPROVED', + updatedBy: 'some-user', + siteId: 'b1ec63c4-87de-4500-bbc9-276039e4bc10', + }; + + const siteCandidate = await SiteCandidate.findByBaseURL(sampleSiteCandidate.getBaseURL()); + + siteCandidate.setBaseURL(updates.baseURL); + siteCandidate.setStatus(updates.status); + siteCandidate.setUpdatedBy(updates.updatedBy); + siteCandidate.setSiteId(updates.siteId); + + await siteCandidate.save(); + + checkSiteCandidate(siteCandidate); + + expect(siteCandidate.getBaseURL()).to.equal(updates.baseURL); + expect(siteCandidate.getStatus()).to.equal(updates.status); + expect(siteCandidate.getUpdatedBy()).to.equal(updates.updatedBy); + expect(siteCandidate.getSiteId()).to.equal(updates.siteId); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/site-top-page/site-top-page.test.js b/packages/spacecat-shared-data-access/test/it/site-top-page/site-top-page.test.js new file mode 100755 index 00000000..1de3f36b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/site-top-page/site-top-page.test.js @@ -0,0 +1,172 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; +import { sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +use(chaiAsPromised); + +function checkSiteTopPage(siteTopPage) { + expect(siteTopPage).to.be.an('object'); + expect(siteTopPage.getId()).to.be.a('string'); + expect(siteTopPage.getSiteId()).to.be.a('string'); + expect(siteTopPage.getUrl()).to.be.a('string'); + expect(siteTopPage.getTraffic()).to.be.a('number'); + expect(siteTopPage.getSource()).to.be.a('string'); + expect(siteTopPage.getTopKeyword()).to.be.a('string'); + expect(siteTopPage.getGeo()).to.be.a('string'); + expect(siteTopPage.getImportedAt()).to.be.a('string'); +} + +describe('SiteTopPage IT', async () => { + let sampleData; + let SiteTopPage; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + SiteTopPage = dataAccess.SiteTopPage; + }); + + it('finds one site top page by id', async () => { + const siteTopPage = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + + expect(siteTopPage).to.be.an('object'); + expect( + sanitizeTimestamps(siteTopPage.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.siteTopPages[0].toJSON()), + ); + }); + + it('gets all site top pages for a site', async () => { + const site = sampleData.sites[0]; + + const siteTopPages = await SiteTopPage.allBySiteId(site.getId()); + + expect(siteTopPages).to.be.an('array'); + expect(siteTopPages.length).to.equal(5); + + siteTopPages.forEach((siteTopPage) => { + checkSiteTopPage(siteTopPage); + expect(siteTopPage.getSiteId()).to.equal(site.getId()); + }); + }); + + it('gets all top pages for a site from a specific source and geo in descending traffic order', async () => { + const site = sampleData.sites[0]; + const source = 'ahrefs'; + const geo = 'global'; + + const siteTopPages = await SiteTopPage.allBySiteIdAndSourceAndGeo( + site.getId(), + source, + geo, + { order: 'desc' }, + ); + + expect(siteTopPages).to.be.an('array'); + expect(siteTopPages.length).to.equal(5); + + siteTopPages.forEach((siteTopPage) => { + checkSiteTopPage(siteTopPage); + expect(siteTopPage.getSiteId()).to.equal(site.getId()); + expect(siteTopPage.getSource()).to.equal(source); + expect(siteTopPage.getGeo()).to.equal(geo); + }); + + for (let i = 1; i < siteTopPages.length; i += 1) { + expect(siteTopPages[i - 1].getTraffic()).to.be.at.least(siteTopPages[i].getTraffic()); + } + }); + + it('creates a site top page', async () => { + const data = { + siteId: sampleData.sites[0].getId(), + url: 'https://www.example.com', + traffic: 100, + source: 'google', + topKeyword: 'example', + geo: 'US', + importedAt: '2024-12-06T08:35:24.125Z', + }; + const siteTopPage = await SiteTopPage.create(data); + + checkSiteTopPage(siteTopPage); + + expect(siteTopPage.getSiteId()).to.equal(data.siteId); + expect(siteTopPage.getUrl()).to.equal(data.url); + expect(siteTopPage.getTraffic()).to.equal(data.traffic); + expect(siteTopPage.getSource()).to.equal(data.source); + expect(siteTopPage.getTopKeyword()).to.equal(data.topKeyword); + expect(siteTopPage.getGeo()).to.equal(data.geo); + expect(siteTopPage.getImportedAt()).to.equal(data.importedAt); + }); + + it('updates a site top page', async () => { + const siteTopPage = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + + const updates = { + traffic: 200, + source: 'bing', + topKeyword: 'example2', + geo: 'CA', + importedAt: '2024-12-07T08:35:24.125Z', + }; + + siteTopPage + .setTraffic(updates.traffic) + .setSource(updates.source) + .setTopKeyword(updates.topKeyword) + .setGeo(updates.geo) + .setImportedAt(updates.importedAt); + + await siteTopPage.save(); + + const updatedSiteTopPage = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + + checkSiteTopPage(updatedSiteTopPage); + + expect(updatedSiteTopPage.getTraffic()).to.equal(updates.traffic); + expect(updatedSiteTopPage.getSource()).to.equal(updates.source); + expect(updatedSiteTopPage.getTopKeyword()).to.equal(updates.topKeyword); + expect(updatedSiteTopPage.getGeo()).to.equal(updates.geo); + expect(updatedSiteTopPage.getImportedAt()).to.equal(updates.importedAt); + }); + + it('removes a site top page', async () => { + const siteTopPage = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + + await siteTopPage.remove(); + + const notFound = await SiteTopPage.findById(sampleData.siteTopPages[0].getId()); + expect(notFound).to.equal(null); + }); + + it('removes all site top pages for a site', async () => { + const site = sampleData.sites[0]; + + await SiteTopPage.removeForSiteId(site.getId()); + + const siteTopPages = await SiteTopPage.allBySiteId(site.getId()); + expect(siteTopPages).to.be.an('array'); + expect(siteTopPages.length).to.equal(0); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/site/site.test.js b/packages/spacecat-shared-data-access/test/it/site/site.test.js new file mode 100644 index 00000000..a2431ac8 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/site/site.test.js @@ -0,0 +1,281 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { sanitizeTimestamps } from '../../../src/v2/util/util.js'; +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +async function checkSite(site) { + expect(site).to.be.an('object'); + expect(site.getId()).to.be.a('string'); + expect(site.getBaseURL()).to.be.a('string'); + expect(site.getDeliveryType()).to.be.a('string'); + expect(site.getGitHubURL()).to.be.a('string'); + expect(site.getHlxConfig()).to.be.an('object'); + expect(site.getOrganizationId()).to.be.a('string'); + expect(isIsoDate(site.getCreatedAt())).to.be.true; + expect(isIsoDate(site.getUpdatedAt())).to.be.true; + + const audits = await site.getAudits(); + expect(audits).to.be.an('array'); + expect(site.getIsLive()).to.be.a('boolean'); + expect(isIsoDate(site.getIsLiveToggledAt())).to.be.true; +} + +describe('Site IT', async () => { + let sampleData; + let Site; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Site = dataAccess.Site; + }); + + it('gets all sites', async () => { + let sites = await Site.all(); + + expect(sites).to.be.an('array'); + expect(sites.length).to.equal(10); + + sites = sites.sort((a, b) => a.getBaseURL().localeCompare(b.getBaseURL())); + + for (let i = 0; i < sites.length; i += 1) { /* eslint-disable no-await-in-loop */ + await checkSite(sites[i]); + } + }); + + it('gets all sites to audit (only id attributes returned)', async () => { + const siteIds = await Site.allSitesToAudit(); + + expect(siteIds).to.be.an('array'); + expect(siteIds.length).to.equal(10); + + const ids = sampleData.sites.reverse().map((site) => site.getId()); + + expect(siteIds).to.eql(ids); + }); + + it('gets all sites by organization id', async () => { + const organizationId = sampleData.organizations[0].getId(); + const sites = await Site.allByOrganizationId(organizationId); + + expect(sites).to.be.an('array'); + expect(sites.length).to.equal(4); + + for (let i = 0; i < sites.length; i += 1) { /* eslint-disable no-await-in-loop */ + const site = sites[i]; + + await checkSite(site); + + const organization = await site.getOrganization(); + + expect(site.getOrganizationId()).to.equal(organizationId); + expect(organization).to.be.an('object'); + expect( + sanitizeTimestamps(organization.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.organizations[0].toJSON()), + ); + } + }); + + it('gets all sites by delivery type', async () => { + const deliveryType = 'aem_edge'; + const sites = await Site.allByDeliveryType(deliveryType); + + expect(sites).to.be.an('array'); + expect(sites.length).to.equal(5); + + for (let i = 0; i < sites.length; i += 1) { + const site = sites[i]; + // eslint-disable-next-line no-await-in-loop + await checkSite(site); + expect(site.getDeliveryType()).to.equal(deliveryType); + } + }); + + it('gets a site by baseURL', async () => { + const site = await Site.findByBaseURL(sampleData.sites[0].getBaseURL()); + + await checkSite(site); + + expect(site.getBaseURL()).to.equal(sampleData.sites[0].getBaseURL()); + }); + + it('gets a site by id', async () => { + const site = await Site.findById(sampleData.sites[0].getId()); + + await checkSite(site); + + expect(site.getId()).to.equal(sampleData.sites[0].getId()); + }); + + it('adds a new site', async () => { + const newSiteData = { + baseURL: 'https://newexample.com', + gitHubURL: 'https://github.com/some-org/test-repo', + hlxConfig: { + cdnProdHost: 'www.another-example.com', + code: { + owner: 'another-owner', + repo: 'another-repo', + source: { + type: 'github', + url: 'https://github.com/another-owner/another-repo', + }, + }, + content: { + contentBusId: '1234', + source: { + type: 'onedrive', + url: 'https://another-owner.sharepoint.com/:f:/r/sites/SomeFolder/Shared%20Documents/another-site/www', + }, + }, + hlxVersion: 5, + }, + organizationId: sampleData.organizations[0].getId(), + isLive: true, + isLiveToggledAt: '2024-12-06T08:35:24.125Z', + audits: [], + config: { + handlers: { + 'lhs-mobile': { + excludedURLs: ['https://example.com/excluded'], + }, + }, + }, + }; + + const newSite = await Site.create(newSiteData); + await checkSite(newSite); + + expect(newSite.getBaseURL()).to.equal(newSiteData.baseURL); + }); + + it('updates a site', async () => { + const site = await Site.findById(sampleData.sites[0].getId()); + const updates = { + baseURL: 'https://updated-example.com', + deliveryType: 'aem_cs', + gitHubURL: 'https://updated-github.com', + isLive: false, + organizationId: sampleData.organizations[1].getId(), + hlxConfig: { + cdnProdHost: 'www.another-example.com', + code: { + owner: 'another-owner', + repo: 'another-repo', + source: { + type: 'github', + url: 'https://github.com/another-owner/another-repo', + }, + }, + content: { + contentBusId: '1234', + source: { + type: 'onedrive', + url: 'https://another-owner.sharepoint.com/:f:/r/sites/SomeFolder/Shared%20Documents/another-site/www', + }, + }, + hlxVersion: 5, + }, + }; + + site.setBaseURL(updates.baseURL); + site.setDeliveryType(updates.deliveryType); + site.setGitHubURL(updates.gitHubURL); + site.setHlxConfig(updates.hlxConfig); + site.setIsLive(updates.isLive); + site.setOrganizationId(updates.organizationId); + + await site.save(); + + const updatedSite = await Site.findById(site.getId()); + + await checkSite(updatedSite); + + expect(updatedSite.getBaseURL()).to.equal(updates.baseURL); + expect(updatedSite.getDeliveryType()).to.equal(updates.deliveryType); + expect(updatedSite.getGitHubURL()).to.equal(updates.gitHubURL); + expect(updatedSite.getIsLive()).to.equal(updates.isLive); + expect(updatedSite.getOrganizationId()).to.equal(updates.organizationId); + }); + + it('removes a site', async () => { + const site = await Site.findById(sampleData.sites[0].getId()); + + await site.remove(); + + const notFound = await Site.findById(sampleData.sites[0].getId()); + expect(notFound).to.be.null; + }); + + it('gets all audits for a site', async () => { + const site = await Site.findById(sampleData.sites[1].getId()); + const audits = await site.getAudits(); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(10); + + for (let i = 0; i < audits.length; i += 1) { + const audit = audits[i]; + + expect(audit.getId()).to.be.a('string'); + expect(audit.getSiteId()).to.equal(site.getId()); + } + }); + + it('gets all audits for a site by type', async () => { + const site = await Site.findById(sampleData.sites[1].getId()); + const audits = await site.getAuditsByAuditType('cwv'); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(5); + + for (let i = 0; i < audits.length; i += 1) { + const audit = audits[i]; + + expect(audit.getId()).to.be.a('string'); + expect(audit.getSiteId()).to.equal(site.getId()); + expect(audit.getAuditType()).to.equal('cwv'); + } + }); + + it('gets all audits for a site by type and auditAt', async () => { + const site = await Site.findById(sampleData.sites[1].getId()); + const audits = await site.getAuditsByAuditTypeAndAuditedAt('cwv', '2024-12-03T08:00:55.754Z'); + + expect(audits).to.be.an('array'); + expect(audits.length).to.equal(5); + + for (let i = 0; i < audits.length; i += 1) { + const audit = audits[i]; + + expect(audit.getId()).to.be.a('string'); + expect(audit.getSiteId()).to.equal(site.getId()); + expect(audit.getAuditType()).to.equal('cwv'); + expect(audit.getAuditedAt()).to.equal('2024-12-03T08:00:55.754Z'); + } + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js b/packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js new file mode 100644 index 00000000..57ef6ceb --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/suggestion/suggestion.test.js @@ -0,0 +1,248 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-console */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { validate as uuidValidate } from 'uuid'; + +import { ValidationError } from '../../../src/index.js'; +import { sanitizeIdAndAuditFields, sanitizeTimestamps } from '../../../src/v2/util/util.js'; + +import { getDataAccess } from '../util/db.js'; +import { seedDatabase } from '../util/seed.js'; + +use(chaiAsPromised); + +describe('Suggestion IT', async () => { + let sampleData; + let Suggestion; + + before(async () => { + sampleData = await seedDatabase(); + + const dataAccess = getDataAccess(); + Suggestion = dataAccess.Suggestion; + }); + + it('finds one suggestion by id', async () => { + const sampleSuggestion = sampleData.suggestions[6]; + + const suggestion = await Suggestion.findById(sampleSuggestion.getId()); + + expect(suggestion).to.be.an('object'); + expect( + sanitizeTimestamps(suggestion.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleSuggestion.toJSON()), + ); + + const opportunity = await suggestion.getOpportunity(); + expect(opportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.opportunities[2].toJSON()), + ); + }); + + it('resolves associations for a suggestion', async () => { + const sampleSuggestion = sampleData.suggestions[6]; + + const suggestion = await Suggestion.findById(sampleSuggestion.getId(), { resolve: true }); + + const opportunity = await suggestion.getOpportunity(); + expect(opportunity).to.be.an('object'); + expect(opportunity.getId()).to.equal(suggestion.getOpportunityId()); + expect(opportunity.getId()).to.equal(sampleData.opportunities[2].getId()); + + const site = await opportunity.getSite(); + expect(site).to.be.an('object'); + expect(site.getId()).to.equal(opportunity.getSiteId()); + expect(site.getId()).to.equal(sampleData.sites[0].getId()); + + const organization = await site.getOrganization(); + expect(organization).to.be.an('object'); + expect(organization.getId()).to.equal(site.getOrganizationId()); + expect(organization.getId()).to.equal(sampleData.organizations[0].getId()); + }); + + it('gets all suggestions by opportunityId', async () => { + const sampleOpportunity = sampleData.opportunities[0]; + const suggestions = await Suggestion.allByOpportunityId(sampleOpportunity.getId()); + + expect(suggestions).to.be.an('array').with.length(3); + + suggestions.forEach((suggestion) => { + expect(suggestion.getOpportunityId()).to.equal(sampleOpportunity.getId()); + }); + + const opportunity = await suggestions[0].getOpportunity(); + expect(opportunity).to.be.an('object'); + expect( + sanitizeTimestamps(opportunity.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleOpportunity.toJSON()), + ); + + const suggestionsFromOpportunity = await opportunity.getSuggestions(); + expect(suggestionsFromOpportunity).to.be.an('array').with.length(3); + suggestionsFromOpportunity.forEach((suggestion) => { + expect(suggestion.getOpportunityId()).to.equal(sampleOpportunity.getId()); + }); + }); + + it('gets all suggestions by opportunityId and status', async () => { + const suggestions = await Suggestion.allByOpportunityIdAndStatus( + sampleData.opportunities[0].getId(), + 'NEW', + ); + + expect(suggestions).to.be.an('array').with.length(2); + + suggestions.forEach((suggestion) => { + expect(suggestion.getOpportunityId()).to.equal(sampleData.opportunities[0].getId()); + expect(suggestion.getStatus()).to.equal('NEW'); + }); + }); + + it('updates one suggestion by id', async () => { + // retrieve the suggestion by ID + const suggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); + expect(suggestion).to.be.an('object'); + expect( + sanitizeTimestamps(suggestion.toJSON()), + ).to.eql( + sanitizeTimestamps(sampleData.suggestions[0].toJSON()), + ); + + // apply updates + const updates = { + status: 'APPROVED', + }; + + await suggestion + .setStatus(updates.status) + .save(); + + // validate in-memory updates + expect(suggestion.getStatus()).to.equal(updates.status); + + const original = sanitizeTimestamps(sampleData.suggestions[0].toJSON()); + delete original.status; + const updated = sanitizeTimestamps(suggestion.toJSON()); + delete updated.status; + + expect(updated).to.eql(original); + + // validate persistence of updates + const storedSuggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); + expect(storedSuggestion.getStatus()).to.equal(updates.status); + + // validate timestamps or audit logs + expect(new Date(storedSuggestion.toJSON().updatedAt)).to.be.greaterThan( + new Date(sampleData.suggestions[0].toJSON().updatedAt), + ); + + // validate persisted record matches in-memory state + const storedWithoutUpdatedAt = { ...storedSuggestion.toJSON() }; + const inMemoryWithoutUpdatedAt = { ...suggestion.toJSON() }; + delete storedWithoutUpdatedAt.updatedAt; + delete inMemoryWithoutUpdatedAt.updatedAt; + + expect(storedWithoutUpdatedAt).to.eql(inMemoryWithoutUpdatedAt); + }); + + it('adds many suggestions to an opportunity', async () => { + const opportunity = sampleData.opportunities[0]; + const data = [ + { + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + data: { foo: 'bar' }, + }, + { + type: 'REDIRECT_UPDATE', + rank: 1, + status: 'APPROVED', + data: { foo: 'bar' }, + }, + ]; + + const suggestions = await opportunity.addSuggestions(data); + + expect(suggestions).to.be.an('object'); + expect(suggestions.createdItems).to.be.an('array').with.length(2); + expect(suggestions.errorItems).to.be.an('array').with.length(0); + + suggestions.createdItems.forEach((suggestion, index) => { + expect(suggestion).to.be.an('object'); + + expect(suggestion.getOpportunityId()).to.equal(opportunity.getId()); + expect(uuidValidate(suggestion.getId())).to.be.true; + expect(isIsoDate(suggestion.getCreatedAt())).to.be.true; + expect(isIsoDate(suggestion.getUpdatedAt())).to.be.true; + + const record = sanitizeIdAndAuditFields('Suggestion', suggestion.toJSON()); + delete record.opportunityId; + + expect(record).to.eql(data[index]); + }); + }); + + it('updates the status of multiple suggestions', async () => { + const suggestions = sampleData.suggestions.slice(0, 3); + + await Suggestion.bulkUpdateStatus(suggestions, 'APPROVED'); + + const updatedSuggestions = await Promise.all( + suggestions.map((suggestion) => Suggestion.findById(suggestion.getId())), + ); + + updatedSuggestions.forEach((suggestion) => { + expect(suggestion.getStatus()).to.equal('APPROVED'); + }); + }); + + it('throws an error when adding a suggestion with invalid opportunity id', async () => { + const data = [ + { + opportunityId: 'invalid-opportunity-id', + type: 'CODE_CHANGE', + rank: 0, + status: 'NEW', + data: { foo: 'bar' }, + }, + ]; + + const results = await Suggestion.createMany(data); + + expect(results.errorItems).to.be.an('array').with.length(1); + expect(results.createdItems).to.be.an('array').with.length(0); + expect(results.errorItems[0].error).to.be.an.instanceOf(ValidationError); + expect(results.errorItems[0].item).to.eql(data[0]); + }); + + it('removes a suggestion', async () => { + const suggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); + + await suggestion.remove(); + + const notFound = await Suggestion.findById(sampleData.suggestions[0].getId()); + expect(notFound).to.be.null; + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/util/db.js b/packages/spacecat-shared-data-access/test/it/util/db.js index 0def934c..8d1a6450 100755 --- a/packages/spacecat-shared-data-access/test/it/util/db.js +++ b/packages/spacecat-shared-data-access/test/it/util/db.js @@ -10,61 +10,52 @@ * governing permissions and limitations under the License. */ -import { DynamoDB } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'; +import { DynamoDB } from '@aws-sdk/client-dynamodb'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { spawn } from 'dynamo-db-local'; - -import { sleep } from '../../unit/util.js'; +import { createDataAccess } from '../../../src/service/index.js'; -async function waitForDynamoDBStartup(url, timeout = 20000, interval = 500) { - const startTime = Date.now(); - while (Date.now() - startTime < timeout) { - try { - // eslint-disable-next-line no-await-in-loop - const response = await fetch(url); - if (response.status === 400) { - return; - } - } catch (error) { - // eslint-disable-next-line no-console - console.log('DynamoDB Local not yet started', error.message); - } - // eslint-disable-next-line no-await-in-loop - await sleep(interval); - } - throw new Error('DynamoDB Local did not start within the expected time'); -} +export const TEST_DA_CONFIG = { + indexNameAllImportJobsByDateRange: 'spacecat-services-all-import-jobs-by-date-range', + indexNameAllImportJobsByStatus: 'spacecat-services-all-import-jobs-by-status', + indexNameAllKeyEventsBySiteId: 'spacecat-services-key-events-by-site-id', + indexNameAllLatestAuditScores: 'spacecat-services-all-latest-audit-scores', + indexNameAllOrganizations: 'spacecat-services-all-organizations', + indexNameAllOrganizationsByImsOrgId: 'spacecat-services-all-organizations-by-ims-org-id', + indexNameAllSites: 'spacecat-services-all-sites', + indexNameAllSitesByDeliveryType: 'spacecat-services-all-sites-by-delivery-type', + indexNameAllSitesOrganizations: 'spacecat-services-all-sites-organizations', + indexNameApiKeyByHashedApiKey: 'spacecat-services-api-key-by-hashed-api-key', + indexNameApiKeyByImsUserIdAndImsOrgId: 'spacecat-services-api-key-by-ims-user-id-and-ims-org-id', + indexNameImportUrlsByJobIdAndStatus: 'spacecat-services-all-import-urls-by-job-id-and-status', + pkAllConfigurations: 'ALL_CONFIGURATIONS', + pkAllImportJobs: 'ALL_IMPORT_JOBS', + pkAllLatestAudits: 'ALL_LATEST_AUDITS', + pkAllOrganizations: 'ALL_ORGANIZATIONS', + pkAllSites: 'ALL_SITES', + tableNameApiKeys: 'spacecat-services-api-keys', + tableNameAudits: 'spacecat-services-audits', + tableNameConfigurations: 'spacecat-services-configurations', + tableNameData: 'spacecat-services-data', + tableNameExperiments: 'spacecat-services-experiments', + tableNameImportJobs: 'spacecat-services-import-jobs', + tableNameImportUrls: 'spacecat-services-import-urls', + tableNameKeyEvents: 'spacecat-services-key-events', + tableNameLatestAudits: 'spacecat-services-latest-audits', + tableNameOrganizations: 'spacecat-services-organizations', + tableNameSiteCandidates: 'spacecat-services-site-candidates', + tableNameSiteTopPages: 'spacecat-services-site-top-pages', + tableNameSites: 'spacecat-services-sites', + tableNameSpacecatData: 'spacecat-data', +}; -let dynamoDbLocalProcess = null; -let dbClient = null; let docClient = null; -const getDynamoClients = async () => { - if (dynamoDbLocalProcess === null) { - process.env.AWS_REGION = 'local'; - process.env.AWS_ENDPOINT_URL_DYNAMODB = 'http://127.0.0.1:8000'; - process.env.AWS_DEFAULT_REGION = 'local'; - process.env.AWS_ACCESS_KEY_ID = 'dummy'; - process.env.AWS_SECRET_ACCESS_KEY = 'dummy'; - - dynamoDbLocalProcess = spawn({ - detached: true, - stdio: 'inherit', - port: 8000, - sharedDb: true, - }); - - await waitForDynamoDBStartup('http://127.0.0.1:8000'); - - process.on('SIGINT', () => { - if (dynamoDbLocalProcess) { - dynamoDbLocalProcess.kill(); - } - process.exit(); - }); - +const getDynamoClients = (config = {}) => { + let dbClient; + if (config?.region && config?.credentials) { + dbClient = new DynamoDB(config); + } else { dbClient = new DynamoDB({ endpoint: 'http://127.0.0.1:8000', region: 'local', @@ -73,18 +64,15 @@ const getDynamoClients = async () => { secretAccessKey: 'dummy', }, }); - docClient = DynamoDBDocument.from(dbClient); } + docClient = DynamoDBDocument.from(dbClient); return { dbClient, docClient }; }; -const closeDynamoClients = async () => { - if (dynamoDbLocalProcess) { - dynamoDbLocalProcess.kill(); - dynamoDbLocalProcess = null; - await sleep(2000); - } +export const getDataAccess = (config) => { + const { dbClient } = getDynamoClients(config); + return createDataAccess(TEST_DA_CONFIG, console, dbClient); }; -export { getDynamoClients, closeDynamoClients }; +export { getDynamoClients }; diff --git a/packages/spacecat-shared-data-access/test/it/util/generateSampleData.js b/packages/spacecat-shared-data-access/test/it/util/generateLegacySampleData.js similarity index 85% rename from packages/spacecat-shared-data-access/test/it/util/generateSampleData.js rename to packages/spacecat-shared-data-access/test/it/util/generateLegacySampleData.js index 4aaec9fa..037f3177 100644 --- a/packages/spacecat-shared-data-access/test/it/util/generateSampleData.js +++ b/packages/spacecat-shared-data-access/test/it/util/generateLegacySampleData.js @@ -14,46 +14,13 @@ import { v4 as uuidv4 } from 'uuid'; -import schema from '../../../docs/schema.json' with { type: 'json' }; import { SITE_CANDIDATE_STATUS } from '../../../src/models/site-candidate.js'; import { createKeyEvent, KEY_EVENT_TYPES } from '../../../src/models/key-event.js'; import { KeyEventDto } from '../../../src/dto/key-event.js'; import { generateRandomAudit } from './auditUtils.js'; -import { createTable, deleteTable } from './tableOperations.js'; import { getDynamoClients } from './db.js'; -/** - * Creates all tables defined in a schema. - * - * Iterates over a predefined schema object and creates each table using the createTable function. - * The schema object should define all required attributes and configurations for each table. - * - * @param {AWS.DynamoDB.DocumentClient} dbClient - The DynamoDB client to use for creating tables. - */ -async function createTablesFromSchema(dbClient) { - const creationPromises = schema.DataModel.map( - (tableDefinition) => createTable(dbClient, tableDefinition), - ); - await Promise.all(creationPromises); -} - -/** - * Deletes a predefined set of tables from the database. - * - * Iterates over a list of table names and deletes each one using the deleteTable function. - * This is typically used to clean up the database before creating new tables or - * generating test data. - * - * @param {Object} dbClient - The DynamoDB client to use for creating tables. - * @param {Array} tableNames - An array of table names to delete. - * @returns {Promise} A promise that resolves when all tables have been deleted. - */ -export async function deleteExistingTables(dbClient, tableNames) { - const deletionPromises = tableNames.map((tableName) => deleteTable(dbClient, tableName)); - await Promise.all(deletionPromises); -} - /** * Performs a batch write operation for a specified table in DynamoDB. * @@ -151,7 +118,7 @@ function generateAuditData( * // Example usage * generateSampleData(20, 10); // Generates 20 sites with 10 audits per type for each site */ -export default async function generateSampleData( +export default async function generateLegacySampleData( config, numberOfOrganizations = 3, numberOfSites = 10, @@ -161,23 +128,8 @@ export default async function generateSampleData( numberOfKeyEvents = 10, numberOfExperiments = 3, ) { - const { dbClient, docClient } = await getDynamoClients(); + const { docClient } = getDynamoClients(); console.time('Sample data generated in'); - await deleteExistingTables(dbClient, [ - config.tableNameSites, - config.tableNameSiteCandidates, - config.tableNameAudits, - config.tableNameLatestAudits, - config.tableNameOrganizations, - config.tableNameConfigurations, - config.tableNameSiteTopPages, - config.tableNameKeyEvents, - config.tableNameExperiments, - config.tableNameApiKeys, - config.tableNameImportJobs, - config.tableNameImportUrls, - ]); - await createTablesFromSchema(dbClient); const auditTypes = ['lhs-mobile', 'cwv']; const sites = []; diff --git a/packages/spacecat-shared-data-access/test/it/util/seed.js b/packages/spacecat-shared-data-access/test/it/util/seed.js new file mode 100644 index 00000000..8d4ba234 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/util/seed.js @@ -0,0 +1,80 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-disable no-console */ + +import { idNameToEntityName } from '../../../src/v2/util/util.js'; +import fixtures from '../../fixtures/index.fixtures.js'; + +import generateLegacySampleData from './generateLegacySampleData.js'; +import { getDataAccess, getDynamoClients, TEST_DA_CONFIG } from './db.js'; +import { createTablesFromSchema, deleteExistingTables } from './tableOperations.js'; + +const resetDatabase = async () => { + const { dbClient } = getDynamoClients(); + await deleteExistingTables(dbClient, [ + TEST_DA_CONFIG.tableNameApiKeys, + TEST_DA_CONFIG.tableNameAudits, + TEST_DA_CONFIG.tableNameConfigurations, + TEST_DA_CONFIG.tableNameData, + TEST_DA_CONFIG.tableNameExperiments, + TEST_DA_CONFIG.tableNameImportJobs, + TEST_DA_CONFIG.tableNameImportUrls, + TEST_DA_CONFIG.tableNameKeyEvents, + TEST_DA_CONFIG.tableNameLatestAudits, + TEST_DA_CONFIG.tableNameOrganizations, + TEST_DA_CONFIG.tableNameSiteCandidates, + TEST_DA_CONFIG.tableNameSiteTopPages, + TEST_DA_CONFIG.tableNameSites, + ]); + await createTablesFromSchema(dbClient); +}; + +const seedV2Fixtures = async () => { + const dataAccess = getDataAccess(); + const sampleData = {}; + + for (const [key, data] of Object.entries(fixtures)) { + console.log(`Seeding ${key}...`); + + if (!Array.isArray(data) || data.length === 0) { + console.log(`No data to seed for ${key}.`); + // eslint-disable-next-line no-continue + continue; + } + + const modelName = idNameToEntityName(key); + const Model = dataAccess[modelName]; + + if (!Model) { + throw new Error(`Model not found for ${modelName}`); + } + + // eslint-disable-next-line no-await-in-loop + const result = await Model.createMany(data); + sampleData[key] = result.createdItems; + + if (result.errorItems.length > 0) { + throw new Error(`Error seeding ${key}: ${JSON.stringify(result.errorItems, null, 2)}`); + } + + console.log(`Successfully seeded ${key}.`); + } + + return sampleData; +}; + +export const seedDatabase = async () => { + await resetDatabase(); + await generateLegacySampleData(TEST_DA_CONFIG); + return seedV2Fixtures(); +}; diff --git a/packages/spacecat-shared-data-access/test/it/util/tableOperations.js b/packages/spacecat-shared-data-access/test/it/util/tableOperations.js old mode 100644 new mode 100755 index 54af94f6..63e57fd3 --- a/packages/spacecat-shared-data-access/test/it/util/tableOperations.js +++ b/packages/spacecat-shared-data-access/test/it/util/tableOperations.js @@ -14,6 +14,8 @@ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb'; +import schema from '../../../docs/schema.json' with { type: 'json' }; + /** * Creates a DynamoDB table based on the provided table definition. * @@ -160,4 +162,40 @@ async function deleteTable(dbClient, tableName) { } } -export { createTable, deleteTable }; +/** + * Creates all tables defined in a schema. + * + * Iterates over a predefined schema object and creates each table using the createTable function. + * The schema object should define all required attributes and configurations for each table. + * + * @param {AWS.DynamoDB.DocumentClient} dbClient - The DynamoDB client to use for creating tables. + */ +async function createTablesFromSchema(dbClient) { + const creationPromises = schema.DataModel.map( + (tableDefinition) => createTable(dbClient, tableDefinition), + ); + await Promise.all(creationPromises); +} + +/** + * Deletes a predefined set of tables from the database. + * + * Iterates over a list of table names and deletes each one using the deleteTable function. + * This is typically used to clean up the database before creating new tables or + * generating test data. + * + * @param {Object} dbClient - The DynamoDB client to use for creating tables. + * @param {Array} tableNames - An array of table names to delete. + * @returns {Promise} A promise that resolves when all tables have been deleted. + */ +async function deleteExistingTables(dbClient, tableNames) { + const deletionPromises = tableNames.map((tableName) => deleteTable(dbClient, tableName)); + await Promise.all(deletionPromises); +} + +export { + createTablesFromSchema, + deleteExistingTables, + createTable, + deleteTable, +}; diff --git a/packages/spacecat-shared-data-access/test/it/util/util.js b/packages/spacecat-shared-data-access/test/it/util/util.js old mode 100644 new mode 100755 index c2ce6fc1..0bf08dd7 --- a/packages/spacecat-shared-data-access/test/it/util/util.js +++ b/packages/spacecat-shared-data-access/test/it/util/util.js @@ -24,4 +24,49 @@ const getRandomDecimal = (precision) => parseFloat(Math.random().toFixed(precisi // Generates a random integer up to a given maximum const getRandomInt = (max) => Math.floor(Math.random() * max); -export { randomDate, getRandomDecimal, getRandomInt }; +const removeElectroProperties = (record) => { /* eslint-disable no-underscore-dangle */ + const cleanedRecord = { ...record }; + + delete cleanedRecord.sk; + delete cleanedRecord.pk; + delete cleanedRecord.gsi1pk; + delete cleanedRecord.gsi1sk; + delete cleanedRecord.gsi2pk; + delete cleanedRecord.gsi2sk; + delete cleanedRecord.gsi3pk; + delete cleanedRecord.gsi3sk; + delete cleanedRecord.gsi4pk; + delete cleanedRecord.gsi4sk; + delete cleanedRecord.__edb_e__; + delete cleanedRecord.__edb_v__; + + return cleanedRecord; +}; + +const sanitizeRecord = (record, idName) => { + const sanitizedRecord = removeElectroProperties({ ...record }); + + delete sanitizedRecord[idName]; + delete sanitizedRecord.createdAt; + delete sanitizedRecord.updatedAt; + + return sanitizedRecord; +}; + +const getExecutionOptions = (options) => { + const { limit, order = 'asc' } = options; + + return { + ...(limit > 0 && { limit }), + order, + }; +}; + +export { + getExecutionOptions, + getRandomDecimal, + getRandomInt, + randomDate, + removeElectroProperties, + sanitizeRecord, +}; diff --git a/packages/spacecat-shared-data-access/test/it/v2/index.test.js b/packages/spacecat-shared-data-access/test/it/v2/index.test.js deleted file mode 100755 index fe6ccd38..00000000 --- a/packages/spacecat-shared-data-access/test/it/v2/index.test.js +++ /dev/null @@ -1,545 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ -/* eslint-disable no-console */ - -import { isIsoDate } from '@adobe/spacecat-shared-utils'; - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import { v4 as uuid, validate as uuidValidate } from 'uuid'; - -import SCHEMA from '../../../docs/schema.json' with { type: 'json' }; -import { createDataAccess } from '../../../src/service/index.js'; -import { ValidationError } from '../../../src/index.js'; - -import { closeDynamoClients, getDynamoClients } from '../util/db.js'; -import { createTable, deleteTable } from '../util/tableOperations.js'; - -use(chaiAsPromised); - -const DATA_TABLE_NAME = 'spacecat-services-data'; - -const setupDb = async (client, table) => { - await deleteTable(client, table); - - const schema = SCHEMA.DataModel.find((model) => model.TableName === table); - await createTable(client, schema); -}; - -const generateSampleData = async (dataAccess, siteId) => { - const { Opportunity, Suggestion } = dataAccess; - const sampleData = { opportunities: [], suggestions: [] }; - - for (let i = 0; i < 10; i += 1) { - const type = i % 2 === 0 ? 'broken-backlinks' : 'broken-internal-links'; - const status = i % 2 === 0 ? 'NEW' : 'IN_PROGRESS'; - const data = type === 'broken-backlinks' - ? { brokenLinks: [`foo-${i}`] } - : { brokenInternalLinks: [`bar-${i}`] }; - - // eslint-disable-next-line no-await-in-loop - const opportunity = await Opportunity.create({ - siteId, - auditId: uuid(), - title: `Opportunity ${i}`, - description: `Description ${i}`, - runbook: `https://example${i}.com`, - type, - origin: 'AI', - guidance: { - foo: `bar-${i}`, - }, - status, - data, - }); - - sampleData.opportunities.push(opportunity); - - // generate suggestions for each opportunity - for (let j = 0; j < 3; j += 1) { - // eslint-disable-next-line no-await-in-loop - const suggestion = await Suggestion.create({ - opportunityId: opportunity.getId(), - title: `Suggestion ${j} for Opportunity ${i}`, - description: `Description for Suggestion ${j} of Opportunity ${i}`, - data: { foo: `bar-${j}` }, - type: 'CODE_CHANGE', - rank: j, - status: 'NEW', - }); - - sampleData.suggestions.push(suggestion); - } - } - - return sampleData; -}; - -const removeElectroProperties = (record) => { /* eslint-disable no-underscore-dangle */ - const cleanedRecord = { ...record }; - delete cleanedRecord.opportunityId; - delete cleanedRecord.suggestionId; - delete cleanedRecord.createdAt; - delete cleanedRecord.updatedAt; - delete cleanedRecord.sk; - delete cleanedRecord.pk; - delete cleanedRecord.gsi1pk; - delete cleanedRecord.gsi1sk; - delete cleanedRecord.gsi2pk; - delete cleanedRecord.gsi2sk; - delete cleanedRecord.__edb_e__; - delete cleanedRecord.__edb_v__; - - return cleanedRecord; -}; - -// eslint-disable-next-line func-names -describe('Opportunity & Suggestion IT', function () { - this.timeout(30000); - - const siteId = uuid(); - - let dataAccess; - let sampleData; - - before(async () => { - const { dbClient } = await getDynamoClients(); - - await setupDb(dbClient, DATA_TABLE_NAME); - - dataAccess = createDataAccess({ tableNameData: DATA_TABLE_NAME }, console); - - sampleData = await generateSampleData(dataAccess, siteId); - }); - - after(async () => { - await closeDynamoClients(); - }); - - describe('Opportunity', () => { - it('finds one opportunity by id', async () => { - const { Opportunity } = dataAccess; - - const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - - expect(opportunity).to.be.an('object'); - expect(opportunity.record).to.eql(sampleData.opportunities[0].record); - - const suggestions = await opportunity.getSuggestions(); - expect(suggestions).to.be.an('array').with.length(3); - - const parentOpportunity = await suggestions[0].getOpportunity(); - expect(parentOpportunity).to.be.an('object'); - expect(parentOpportunity.record).to.eql(sampleData.opportunities[0].record); - }); - - it('finds all opportunities by siteId and status', async () => { - const { Opportunity } = dataAccess; - - const opportunities = await Opportunity.allBySiteIdAndStatus(siteId, 'NEW'); - - expect(opportunities).to.be.an('array').with.length(5); - }); - - it('partially updates one opportunity by id', async () => { - const { Opportunity } = dataAccess; - - // retrieve the opportunity by ID - const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - expect(opportunity).to.be.an('object'); - expect(opportunity.record).to.eql(sampleData.opportunities[0].record); - - // apply updates - const updates = { - runbook: 'https://example-updated.com', - status: 'IN_PROGRESS', - }; - - opportunity - .setRunbook(updates.runbook) - .setStatus(updates.status); - - expect(() => { - opportunity.setAuditId('invalid-audit-id'); - }).to.throw(Error); - - await opportunity.save(); - - // validate in-memory updates - expect(opportunity.getRunbook()).to.equal(updates.runbook); - expect(opportunity.getStatus()).to.equal(updates.status); - - // validate unchanged fields - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - runbook, status, updatedAt, ...originalUnchangedFields - } = sampleData.opportunities[0].record; - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - runbook: _, status: __, updatedAt: ___, ...actualUnchangedFields - } = opportunity.record; - - expect(actualUnchangedFields).to.eql(originalUnchangedFields); - - // validate persistence of updates - const storedOpportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - expect(storedOpportunity.getRunbook()).to.equal(updates.runbook); - expect(storedOpportunity.getStatus()).to.equal(updates.status); - - // validate timestamps or audit logs - expect(new Date(storedOpportunity.record.updatedAt)).to.be.greaterThan( - new Date(sampleData.opportunities[0].record.updatedAt), - ); - - // validate persisted record matches in-memory state - const storedWithoutUpdatedAt = { ...storedOpportunity.record }; - const inMemoryWithoutUpdatedAt = { ...opportunity.record }; - delete storedWithoutUpdatedAt.updatedAt; - delete inMemoryWithoutUpdatedAt.updatedAt; - - expect(storedWithoutUpdatedAt).to.eql(inMemoryWithoutUpdatedAt); - }); - - it('finds all opportunities by siteId', async () => { - const { Opportunity } = dataAccess; - - const opportunities = await Opportunity.allBySiteId(siteId); - - expect(opportunities).to.be.an('array').with.length(10); - }); - - it('creates a new opportunity', async () => { - const { Opportunity } = dataAccess; - const data = { - siteId, - auditId: uuid(), - title: 'New Opportunity', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - guidance: { foo: 'bar' }, - data: { brokenLinks: ['https://example.com'] }, - }; - - const opportunity = await Opportunity.create(data); - - expect(opportunity).to.be.an('object'); - - expect(uuidValidate(opportunity.getId())).to.be.true; - expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; - expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; - - delete opportunity.record.opportunityId; - delete opportunity.record.createdAt; - delete opportunity.record.updatedAt; - expect(opportunity.record).to.eql(data); - }); - - it('deletes an opportunity', async () => { - const { Opportunity } = dataAccess; - - const opportunity = await Opportunity.findById(sampleData.opportunities[0].getId()); - - await opportunity.remove(); - - const notFound = await Opportunity.findById(sampleData.opportunities[0].getId()); - await expect(notFound).to.be.null; - }); - - it('creates many opportunities', async () => { - const { Opportunity } = dataAccess; - const data = [ - { - siteId, - auditId: uuid(), - title: 'New Opportunity 1', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - data: { brokenLinks: ['https://example.com'] }, - }, - { - siteId, - auditId: uuid(), - title: 'New Opportunity 2', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-internal-links', - origin: 'AI', - status: 'NEW', - data: { brokenInternalLinks: ['https://example.com'] }, - }, - ]; - - const opportunities = await Opportunity.createMany(data); - - expect(opportunities).to.be.an('object'); - expect(opportunities.createdItems).to.be.an('array').with.length(2); - expect(opportunities.errorItems).to.be.an('array').with.length(0); - - opportunities.createdItems.forEach((opportunity, index) => { - expect(opportunity).to.be.an('object'); - - expect(uuidValidate(opportunity.getId())).to.be.true; - expect(isIsoDate(opportunity.getCreatedAt())).to.be.true; - expect(isIsoDate(opportunity.getUpdatedAt())).to.be.true; - - const { record } = opportunity; - delete record.opportunityId; - delete record.createdAt; - delete record.updatedAt; - delete record.sk; - delete record.pk; - delete record.gsi1pk; - delete record.gsi1sk; - delete record.gsi2pk; - delete record.gsi2sk; - // eslint-disable-next-line no-underscore-dangle - delete record.__edb_e__; - // eslint-disable-next-line no-underscore-dangle - delete record.__edb_v__; - expect(record).to.eql(data[index]); - }); - }); - - it('fails to create many opportunities with invalid data', async () => { - const { Opportunity } = dataAccess; - const data = [ - { - siteId, - auditId: uuid(), - title: 'New Opportunity 1', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-backlinks', - origin: 'AI', - status: 'NEW', - data: { brokenLinks: ['https://example.com'] }, - }, - { - siteId, - auditId: uuid(), - title: 'New Opportunity 2', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-internal-links', - origin: 'AI', - status: 'NEW', - data: { brokenInternalLinks: ['https://example.com'] }, - }, - { - siteId, - auditId: uuid(), - title: 'New Opportunity 3', - description: 'Description', - runbook: 'https://example.com', - type: 'broken-internal-links', - origin: 'AI', - status: 'NEW', - data: { brokenInternalLinks: ['https://example.com'] }, - }, - ]; - - data[2].title = null; - - const result = await Opportunity.createMany(data); - - expect(result).to.be.an('object'); - expect(result).to.have.property('createdItems'); - expect(result).to.have.property('errorItems'); - - expect(result.createdItems).to.be.an('array').with.length(2); - expect(removeElectroProperties(result.createdItems[0].record)).to.eql(data[0]); - expect(removeElectroProperties(result.createdItems[1].record)).to.eql(data[1]); - expect(result.errorItems).to.be.an('array').with.length(1); - expect(result.errorItems[0].item).to.eql(data[2]); - expect(result.errorItems[0].error).to.be.an.instanceOf(ValidationError); - }); - }); - - describe('Suggestion', () => { - it('finds one suggestion by id', async () => { - const { Suggestion } = dataAccess; - const sampleSuggestion = sampleData.suggestions[6]; - - const suggestion = await Suggestion.findById(sampleSuggestion.getId()); - - expect(suggestion).to.be.an('object'); - expect(suggestion.record).to.eql(sampleSuggestion.record); - - const opportunity = await suggestion.getOpportunity(); - expect(opportunity).to.be.an('object'); - expect(opportunity.record).to.eql(sampleData.opportunities[2].record); - }); - - it('partially updates one suggestion by id', async () => { - const { Suggestion } = dataAccess; - - // retrieve the suggestion by ID - const suggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); - expect(suggestion).to.be.an('object'); - expect(suggestion.record).to.eql(sampleData.suggestions[0].record); - - // apply updates - const updates = { - status: 'APPROVED', - }; - - await suggestion - .setStatus(updates.status) - .save(); - - // validate in-memory updates - expect(suggestion.getStatus()).to.equal(updates.status); - - // validate unchanged fields - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - status, updatedAt, ...originalUnchangedFields - } = sampleData.suggestions[0].record; - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - status: _, updatedAt: __, ...actualUnchangedFields - } = suggestion.record; - - expect(actualUnchangedFields).to.eql(originalUnchangedFields); - - // validate persistence of updates - const storedSuggestion = await Suggestion.findById(sampleData.suggestions[0].getId()); - expect(storedSuggestion.getStatus()).to.equal(updates.status); - - // validate timestamps or audit logs - expect(new Date(storedSuggestion.record.updatedAt)).to.be.greaterThan( - new Date(sampleData.suggestions[0].record.updatedAt), - ); - - // validate persisted record matches in-memory state - const storedWithoutUpdatedAt = { ...storedSuggestion.record }; - const inMemoryWithoutUpdatedAt = { ...suggestion.record }; - delete storedWithoutUpdatedAt.updatedAt; - delete inMemoryWithoutUpdatedAt.updatedAt; - - expect(storedWithoutUpdatedAt).to.eql(inMemoryWithoutUpdatedAt); - }); - - it('finds all suggestions by opportunityId', async () => { - const { Suggestion } = dataAccess; - - const suggestions = await Suggestion.allByOpportunityId(sampleData.opportunities[0].getId()); - - expect(suggestions).to.be.an('array').with.length(3); - }); - - it('finds all suggestions by opportunityId and status', async () => { - const { Suggestion } = dataAccess; - - const suggestions = await Suggestion.allByOpportunityIdAndStatus( - sampleData.opportunities[0].getId(), - 'NEW', - ); - - expect(suggestions).to.be.an('array').with.length(2); - }); - - it('adds many suggestions to an opportunity', async () => { - const opportunity = sampleData.opportunities[0]; - const data = [ - { - type: 'CODE_CHANGE', - rank: 0, - status: 'NEW', - data: { foo: 'bar' }, - }, - { - type: 'REDIRECT_UPDATE', - rank: 1, - status: 'APPROVED', - data: { foo: 'bar' }, - }, - ]; - - const suggestions = await opportunity.addSuggestions(data); - - expect(suggestions).to.be.an('object'); - expect(suggestions.createdItems).to.be.an('array').with.length(2); - expect(suggestions.errorItems).to.be.an('array').with.length(0); - - suggestions.createdItems.forEach((suggestion, index) => { - expect(suggestion).to.be.an('object'); - - expect(suggestion.getOpportunityId()).to.equal(opportunity.getId()); - expect(uuidValidate(suggestion.getId())).to.be.true; - expect(isIsoDate(suggestion.getCreatedAt())).to.be.true; - expect(isIsoDate(suggestion.getUpdatedAt())).to.be.true; - - const { record } = suggestion; - delete record.opportunityId; - delete record.suggestionId; - delete record.createdAt; - delete record.updatedAt; - delete record.sk; - delete record.pk; - delete record.gsi1pk; - delete record.gsi1sk; - delete record.gsi2pk; - delete record.gsi2sk; - // eslint-disable-next-line no-underscore-dangle - delete record.__edb_e__; - // eslint-disable-next-line no-underscore-dangle - delete record.__edb_v__; - expect(record).to.eql(data[index]); - }); - }); - - it('updates the status of multiple suggestions', async () => { - const { Suggestion } = dataAccess; - - const suggestions = sampleData.suggestions.slice(0, 3); - - await Suggestion.bulkUpdateStatus(suggestions, 'APPROVED'); - - const updatedSuggestions = await Promise.all( - suggestions.map((suggestion) => Suggestion.findById(suggestion.getId())), - ); - - updatedSuggestions.forEach((suggestion) => { - expect(suggestion.getStatus()).to.equal('APPROVED'); - }); - }); - - it('throws an error when adding a suggestion with invalid opportunity id', async () => { - const { Suggestion } = dataAccess; - const data = [ - { - opportunityId: 'invalid-opportunity-id', - type: 'CODE_CHANGE', - rank: 0, - status: 'NEW', - data: { foo: 'bar' }, - }, - ]; - - const results = await Suggestion.createMany(data); - - expect(results.errorItems).to.be.an('array').with.length(1); - expect(results.createdItems).to.be.an('array').with.length(0); - expect(results.errorItems[0].error).to.be.an.instanceOf(ValidationError); - expect(results.errorItems[0].item).to.eql(data[0]); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/index.test.js b/packages/spacecat-shared-data-access/test/unit/index.test.js index 68a6abaa..af9dbf03 100644 --- a/packages/spacecat-shared-data-access/test/unit/index.test.js +++ b/packages/spacecat-shared-data-access/test/unit/index.test.js @@ -25,7 +25,11 @@ describe('Data Access Wrapper Tests', () => { mockFn = sinon.stub().resolves('function response'); mockContext = { env: {}, - log: sinon.spy(), + log: { + info: sinon.spy(), + debug: sinon.spy(), + error: sinon.spy(), + }, }; mockRequest = {}; }); diff --git a/packages/spacecat-shared-data-access/test/unit/service/index.test.js b/packages/spacecat-shared-data-access/test/unit/service/index.test.js index 310eda2a..6ef0f5e3 100644 --- a/packages/spacecat-shared-data-access/test/unit/service/index.test.js +++ b/packages/spacecat-shared-data-access/test/unit/service/index.test.js @@ -111,7 +111,18 @@ describe('Data Access Object Tests', () => { ]; const electroServiceFunctions = [ + 'ApiKey', + 'Audit', + 'Configuration', + 'Experiment', + 'ImportJob', + 'ImportUrl', + 'KeyEvent', 'Opportunity', + 'Organization', + 'Site', + 'SiteCandidate', + 'SiteTopPage', 'Suggestion', ]; diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.collection.test.js new file mode 100755 index 00000000..a226da75 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.collection.test.js @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import ApiKey from '../../../../../src/v2/models/api-key/api-key.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ApiKeyCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + apiKeyId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(ApiKey, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ApiKeyCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.model.test.js new file mode 100755 index 00000000..b8058527 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/api-key/api-key.model.test.js @@ -0,0 +1,197 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import ApiKey from '../../../../../src/v2/models/api-key/api-key.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ApiKeyModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + apiKeyId: 'sug12345', + hashedApiKey: 'someHashedApiKey', + imsUserId: 'someImsUserId', + imsOrgId: 'someImsOrgId', + name: 'someName', + deletedAt: null, + expiresAt: null, + revokedAt: null, + scopes: [ + { + domains: ['someDomain'], + actions: ['someAction'], + }, + ], + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(ApiKey, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the ApiKey instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('apiKeyId', () => { + it('gets apiKeyId', () => { + expect(instance.getId()).to.equal('sug12345'); + }); + }); + + describe('hashedApiKey', () => { + it('gets hashedApiKey', () => { + expect(instance.getHashedApiKey()).to.equal('someHashedApiKey'); + }); + + it('sets hashedApiKey', () => { + const newHashedApiKey = 'newHashedApiKey'; + instance.setHashedApiKey(newHashedApiKey); + expect(instance.getHashedApiKey()).to.equal(newHashedApiKey); + }); + }); + + describe('imsUserId', () => { + it('gets imsUserId', () => { + expect(instance.getImsUserId()).to.equal('someImsUserId'); + }); + + it('sets imsUserId', () => { + const newImsUserId = 'newImsUserId'; + instance.setImsUserId(newImsUserId); + expect(instance.getImsUserId()).to.equal(newImsUserId); + }); + }); + + describe('imsOrgId', () => { + it('gets imsOrgId', () => { + expect(instance.getImsOrgId()).to.equal('someImsOrgId'); + }); + + it('sets imsOrgId', () => { + const newImsOrgId = 'newImsOrgId'; + instance.setImsOrgId(newImsOrgId); + expect(instance.getImsOrgId()).to.equal(newImsOrgId); + }); + }); + + describe('name', () => { + it('gets name', () => { + expect(instance.getName()).to.equal('someName'); + }); + + it('sets name', () => { + const newName = 'newName'; + instance.setName(newName); + expect(instance.getName()).to.equal(newName); + }); + }); + + describe('scopes', () => { + it('gets scopes', () => { + expect(instance.getScopes()).to.deep.equal([ + { + domains: ['someDomain'], + actions: ['someAction'], + }, + ]); + }); + + it('sets scopes', () => { + const newScopes = [ + { + domains: ['newDomain'], + actions: ['newAction'], + }, + ]; + instance.setScopes(newScopes); + expect(instance.getScopes()).to.deep.equal(newScopes); + }); + }); + + describe('isValid', () => { + it('returns true when the ApiKey is valid', () => { + expect(instance.isValid()).to.equal(true); + }); + + it('returns false when the ApiKey is deleted', () => { + instance.setDeletedAt('2022-01-01T00:00:00.000Z'); + expect(instance.isValid()).to.equal(false); + }); + + it('returns false when the ApiKey is revoked', () => { + instance.setRevokedAt('2022-01-01T00:00:00.000Z'); + expect(instance.isValid()).to.equal(false); + }); + + it('returns false when the ApiKey is expired', () => { + instance.setExpiresAt('2022-01-01T00:00:00.000Z'); + expect(instance.isValid()).to.equal(false); + }); + }); + + describe('deletedAt', () => { + it('gets deletedAt', () => { + expect(instance.getDeletedAt()).to.equal(null); + }); + + it('sets deletedAt', () => { + const deletedAtIsoDate = '2024-01-01T00:00:00.000Z'; + instance.setDeletedAt(deletedAtIsoDate); + expect(instance.getDeletedAt()).to.equal(deletedAtIsoDate); + }); + }); + + describe('expiresAt', () => { + it('gets expiresAt', () => { + expect(instance.getExpiresAt()).to.equal(null); + }); + + it('sets expiresAt', () => { + const expiresAtIsoDate = '2024-01-01T00:00:00.000Z'; + instance.setExpiresAt(expiresAtIsoDate); + expect(instance.getExpiresAt()).to.equal(expiresAtIsoDate); + }); + }); + + describe('revokedAt', () => { + it('gets revokedAt', () => { + expect(instance.getRevokedAt()).to.equal(null); + }); + + it('sets revokedAt', () => { + const revokedAtIsoDate = '2024-01-01T00:00:00.000Z'; + instance.setRevokedAt(revokedAtIsoDate); + expect(instance.getRevokedAt()).to.equal(revokedAtIsoDate); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.collection.test.js new file mode 100755 index 00000000..3f902c0c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.collection.test.js @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import Audit from '../../../../../src/v2/models/audit/audit.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('AuditCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + auditId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Audit, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the AuditCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.model.test.js new file mode 100755 index 00000000..6dde89f7 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/audit/audit.model.test.js @@ -0,0 +1,185 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import Audit, { validateAuditResult } from '../../../../../src/v2/models/audit/audit.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('AuditModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + auditId: 'a12345', + auditResult: { foo: 'bar' }, + auditType: 'someAuditType', + auditedAt: '2024-01-01T00:00:00.000Z', + fullAuditRef: 'someFullAuditRef', + isLive: true, + isError: false, + siteId: 'site12345', + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Audit, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Audit instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('auditId', () => { + it('gets auditId', () => { + expect(instance.getId()).to.equal('a12345'); + }); + }); + + describe('auditResult', () => { + it('gets auditResult', () => { + expect(instance.getAuditResult()).to.deep.equal({ foo: 'bar' }); + }); + + it('sets auditResult', () => { + const newAuditResult = { bar: 'baz' }; + instance.setAuditResult(newAuditResult); + expect(instance.getAuditResult()).to.deep.equal(newAuditResult); + }); + }); + + describe('auditType', () => { + it('gets auditType', () => { + expect(instance.getAuditType()).to.equal('someAuditType'); + }); + + it('sets auditType', () => { + const newAuditType = 'someNewAuditType'; + instance.setAuditType(newAuditType); + expect(instance.getAuditType()).to.equal(newAuditType); + }); + }); + + describe('auditedAt', () => { + it('gets auditedAt', () => { + expect(instance.getAuditedAt()).to.equal('2024-01-01T00:00:00.000Z'); + }); + + it('sets auditedAt', () => { + const newAuditedAt = '2024-01-02T00:00:00.000Z'; + instance.setAuditedAt(newAuditedAt); + expect(instance.getAuditedAt()).to.equal(newAuditedAt); + }); + }); + + describe('fullAuditRef', () => { + it('gets fullAuditRef', () => { + expect(instance.getFullAuditRef()).to.equal('someFullAuditRef'); + }); + + it('sets fullAuditRef', () => { + const newFullAuditRef = 'someNewFullAuditRef'; + instance.setFullAuditRef(newFullAuditRef); + expect(instance.getFullAuditRef()).to.equal(newFullAuditRef); + }); + }); + + describe('isLive', () => { + it('gets isLive', () => { + expect(instance.getIsLive()).to.be.true; + }); + + it('sets isLive', () => { + instance.setIsLive(false); + expect(instance.getIsLive()).to.be.false; + }); + }); + + describe('isError', () => { + it('gets isError', () => { + expect(instance.getIsError()).to.be.false; + }); + + it('sets isError', () => { + instance.setIsError(true); + expect(instance.getIsError()).to.be.true; + }); + }); + + describe('siteId', () => { + it('gets siteId', () => { + expect(instance.getSiteId()).to.equal('site12345'); + }); + + it('sets siteId', () => { + const newSiteId = '978cbf56-699c-4e91-b719-13e5fd9a0374'; + instance.setSiteId(newSiteId); + expect(instance.getSiteId()).to.equal(newSiteId); + }); + }); + + describe('getScores', () => { + it('returns the scores from the audit result', () => { + mockRecord.auditResult = { scores: { foo: 'bar' } }; + expect(instance.getScores()).to.deep.equal({ foo: 'bar' }); + }); + }); + + describe('validateAuditResult', () => { + it('throws an error if auditResult is not an object or array', () => { + expect(() => validateAuditResult(null, 'someAuditType')) + .to.throw('Audit result must be an object or array'); + }); + + it('throws an error if auditResult is an object and does not contain scores', () => { + expect(() => validateAuditResult({ foo: 'bar' }, 'lhs-mobile')) + .to.throw("Missing scores property for audit type 'lhs-mobile'"); + }); + + it('throws an error if auditResult is an object and does not contain expected properties', () => { + mockRecord.auditResult = { scores: { foo: 'bar' } }; + expect(() => validateAuditResult(mockRecord.auditResult, 'lhs-desktop')) + .to.throw("Missing expected property 'performance' for audit type 'lhs-desktop'"); + }); + + it('returns true if the auditResult represents a runtime error', () => { + mockRecord.auditResult = { runtimeError: { code: 'someErrorCode' } }; + expect(validateAuditResult(mockRecord.auditResult, 'someAuditType')).to.be.true; + }); + + it('returns true if auditResult is an object and contains expected properties', () => { + mockRecord.auditResult = { + scores: { + performance: 1, seo: 1, accessibility: 1, 'best-practices': 1, + }, + }; + expect(validateAuditResult(mockRecord.auditResult, 'lhs-mobile')).to.be.true; + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js deleted file mode 100644 index aec8e1fd..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js +++ /dev/null @@ -1,316 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use as chaiUse } from 'chai'; -import { ElectroValidationError } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import BaseCollection from '../../../../src/v2/models/base.collection.js'; - -chaiUse(chaiAsPromised); - -describe('BaseCollection', () => { - let baseCollectionInstance; - let mockElectroService; - let mockModelFactory; - let mockLogger; - - const mockRecord = { - id: 'ef39921f-9a02-41db-b491-02c98987d956', - data: { - someKey: 'someValue', - }, - }; - const mockEntityModel = { - data: { ...mockRecord }, - }; - - beforeEach(() => { - mockModelFactory = { - getCollection: stub(), - }; - - mockLogger = { - error: spy(), - warn: spy(), - }; - - mockElectroService = { - entities: { - mockentitymodel: { - get: stub(), - put: stub(), - create: stub(), - query: stub(), - model: { - name: 'mockentitymodel', - table: 'mockentitymodel', - }, - }, - }, - }; - - baseCollectionInstance = new BaseCollection( - mockElectroService, - mockModelFactory, - class MockEntityModel { - constructor(service, factory, data) { - this.data = data; - } - - // eslint-disable-next-line class-methods-use-this - _cacheReference() {} - }, - mockLogger, - ); - }); - - describe('findById', () => { - it('returns the entity if found', async () => { - const mockFindResult = { data: mockRecord }; - mockElectroService.entities.mockentitymodel.get.returns( - { go: () => Promise.resolve(mockFindResult) }, - ); - - const result = await baseCollectionInstance.findById('ef39921f-9a02-41db-b491-02c98987d956'); - expect(result).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.get.calledOnce).to.be.true; - }); - - it('returns null if the entity is not found', async () => { - mockElectroService.entities.mockentitymodel.get.returns( - { go: () => Promise.resolve(null) }, - ); - - const result = await baseCollectionInstance.findById('ef39921f-9a02-41db-b491-02c98987d956'); - expect(result).to.be.null; - expect(mockElectroService.entities.mockentitymodel.get.calledOnce).to.be.true; - }); - }); - - describe('findByIndexKeys', () => { - it('throws error if keys is not provided', async () => { - await expect(baseCollectionInstance.findByIndexKeys()) - .to.be.rejectedWith('Failed to find by index keys [mockentitymodel]: keys are required'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - - it('throws error if index is not found', async () => { - await expect(baseCollectionInstance.findByIndexKeys({ someKey: 'someValue' })) - .to.be.rejectedWith('Failed to find by index keys [mockentitymodel]: index [bySomeKey] not found'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('create', () => { - it('throws an error if the record is empty', async () => { - await expect(baseCollectionInstance.create(null)).to.be.rejectedWith('Failed to create [mockentitymodel]'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - - it('creates a new entity successfully', async () => { - mockElectroService.entities.mockentitymodel.create.returns( - { go: () => Promise.resolve(mockEntityModel) }, - ); - - const result = await baseCollectionInstance.create(mockRecord); - expect(result).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.create.calledOnce).to.be.true; - }); - - it('logs an error and throws when creation fails', async () => { - const error = new Error('Create failed'); - mockElectroService.entities.mockentitymodel.create.returns( - { go: () => Promise.reject(error) }, - ); - - await expect(baseCollectionInstance.create(mockRecord.data)).to.be.rejectedWith('Create failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('createMany', () => { - it('throws an error if the records are empty', async () => { - await expect(baseCollectionInstance.createMany(null)) - .to.be.rejectedWith('Failed to create many [mockentitymodel]: items must be a non-empty array'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - - it('creates multiple entities successfully', async () => { - const mockRecords = [mockRecord, mockRecord]; - const mockPutResults = { - type: 'query', - method: 'batchWrite', - params: { - RequestItems: { - mockentitymodel: [ - { PutRequest: { Item: mockRecord } }, - { PutRequest: { Item: mockRecord } }, - ], - }, - }, - }; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: (options) => { - options.listeners[0](mockPutResults); - options.listeners[0]({ type: 'result' }); - options.listeners[0]({ type: 'query', method: 'ignore' }); - return Promise.resolve({ unprocessed: [] }); - }, - params: () => {}, - }, - ); - - const result = await baseCollectionInstance.createMany(mockRecords); - expect(result.createdItems).to.be.an('array').that.has.length(2); - expect(result.createdItems).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.put.calledThrice).to.be.true; - }); - - it('creates many with a parent entity', async () => { - const mockRecords = [mockRecord, mockRecord]; - const mockPutResults = { - type: 'query', - method: 'batchWrite', - params: { - RequestItems: { - mockentitymodel: [ - { PutRequest: { Item: mockRecord } }, - { PutRequest: { Item: mockRecord } }, - ], - }, - }, - }; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: (options) => { - options.listeners[0](mockPutResults); - options.listeners[0]({ type: 'result' }); - options.listeners[0]({ type: 'query', method: 'ignore' }); - return Promise.resolve({ unprocessed: [] }); - }, - params: () => {}, - }, - ); - - const result = await baseCollectionInstance.createMany( - mockRecords, - { entity: { model: { name: 'mockentitymodel' } } }, - ); - expect(result.createdItems).to.be.an('array').that.has.length(2); - expect(result.createdItems).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.put.calledThrice).to.be.true; - }); - - it('creates some entities successfully with unprocessed items', async () => { - const mockRecords = [mockRecord, mockRecord]; - const mockPutResults = { - type: 'query', - method: 'batchWrite', - params: { - RequestItems: { - mockentitymodel: [ - { PutRequest: { Item: mockRecord } }, - ], - }, - }, - }; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: (options) => { - options.listeners[0](mockPutResults); - return Promise.resolve({ unprocessed: [mockRecord] }); - }, - params: () => {}, - }, - ); - - const result = await baseCollectionInstance.createMany(mockRecords); - expect(result.createdItems).to.be.an('array').that.has.length(1); - expect(result.createdItems).to.deep.include(mockEntityModel); - expect(mockElectroService.entities.mockentitymodel.put.calledThrice).to.be.true; - expect(mockLogger.error.calledOnceWith(`Failed to process all items in batch write for [mockentitymodel]: ${JSON.stringify([mockRecord])}`)).to.be.true; - }); - - it('fails creating some items due to ValidationError', async () => { - const error = new ElectroValidationError('Validation failed'); - mockElectroService.entities.mockentitymodel.put.returns( - { params: () => { throw error; } }, - ); - - const result = await baseCollectionInstance.createMany([mockRecord]); - expect(result.createdItems).to.be.an('array').that.has.length(0); - expect(result.errorItems).to.be.an('array').that.has.length(1); - expect(result.errorItems[0].item).to.deep.include(mockRecord); - }); - - it('logs an error and throws when creation fails', async () => { - const error = new Error('Create failed'); - const mockRecords = [mockRecord, mockRecord]; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: () => Promise.reject(error), - params: () => {}, - }, - ); - - await expect(baseCollectionInstance.createMany(mockRecords)).to.be.rejectedWith('Create failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('_saveMany', () => { /* eslint-disable no-underscore-dangle */ - it('throws an error if the records are empty', async () => { - await expect(baseCollectionInstance._saveMany(null)) - .to.be.rejectedWith('Failed to save many [mockentitymodel]: items must be a non-empty array'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - - it('saves multiple entities successfully', async () => { - const mockRecords = [mockRecord, mockRecord]; - mockElectroService.entities.mockentitymodel.put.returns({ go: () => [] }); - - const result = await baseCollectionInstance._saveMany(mockRecords); - expect(result).to.be.undefined; - expect(mockElectroService.entities.mockentitymodel.put.calledOnce).to.be.true; - }); - - it('saves some entities successfully with unprocessed items', async () => { - const mockRecords = [mockRecord, mockRecord]; - mockElectroService.entities.mockentitymodel.put.returns( - { - go: () => Promise.resolve({ unprocessed: [mockRecord] }), - }, - ); - - const result = await baseCollectionInstance._saveMany(mockRecords); - expect(result).to.be.undefined; - expect(mockElectroService.entities.mockentitymodel.put.calledOnce).to.be.true; - expect(mockLogger.error.calledOnceWith(`Failed to process all items in batch write for [mockentitymodel]: ${JSON.stringify([mockRecord])}`)).to.be.true; - }); - - it('throws error and logs when save fails', async () => { - const error = new Error('Save failed'); - const mockRecords = [mockRecord, mockRecord]; - mockElectroService.entities.mockentitymodel.put.returns( - { go: () => Promise.reject(error) }, - ); - - await expect(baseCollectionInstance._saveMany(mockRecords)).to.be.rejectedWith('Save failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base.model.test.js deleted file mode 100755 index 3eebee30..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/base.model.test.js +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use as chaiUse } from 'chai'; -import { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import BaseModel from '../../../../src/v2/models/base.model.js'; -import OpportunitySchema from '../../../../src/v2/schema/opportunity.schema.js'; - -chaiUse(chaiAsPromised); - -const opportunityEntity = new Entity(OpportunitySchema); - -describe('BaseModel', () => { - let mockElectroService; - let baseModelInstance; - let mockLogger; - let mockModelFactory; - - const mockRecord = { - basemodelId: '12345', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - beforeEach(() => { - mockLogger = { - error: spy(), - }; - - mockModelFactory = { - getCollection: stub(), - }; - - mockElectroService = { - entities: { - basemodel: { - name: 'basemodel', - model: { - name: 'basemodel', - schema: opportunityEntity.model.schema, - original: { - references: { - has_one: [], - has_many: [ - { type: 'has_many', target: 'Suggestions' }, - ], - belongs_to: [], - }, - }, - }, - remove: stub(), - patch: stub(), - }, - }, - }; - - baseModelInstance = new BaseModel(mockElectroService, mockModelFactory, mockRecord, mockLogger); - }); - - describe('base', () => { - it('creates a new instance of BaseModel', () => { - expect(baseModelInstance).to.be.an.instanceOf(BaseModel); - }); - - it('returns when initializeAttributes has no attributes', () => { - mockElectroService.entities.basemodel.model.schema.attributes = {}; - const instance = new BaseModel(mockElectroService, mockModelFactory, {}, mockLogger); - expect(instance).to.be.an.instanceOf(BaseModel); - }); - }); - - describe('getId', () => { - it('returns the ID of the entity', () => { - const id = baseModelInstance.getId(); - expect(id).to.equal('12345'); - }); - }); - - describe('getCreatedAt', () => { - it('returns the creation timestamp in ISO format', () => { - const createdAt = baseModelInstance.getCreatedAt(); - expect(createdAt).to.equal(mockRecord.createdAt); - }); - }); - - describe('getUpdatedAt', () => { - it('returns the updated timestamp in ISO format', () => { - const updatedAt = baseModelInstance.getUpdatedAt(); - expect(updatedAt).to.equal(mockRecord.updatedAt); - }); - }); - - describe('remove', () => { - it('removes the record and returns the current instance', async () => { - mockElectroService.entities.basemodel.remove.returns({ go: () => Promise.resolve() }); - await expect(baseModelInstance.remove()).to.eventually.equal(baseModelInstance); - expect(mockElectroService.entities.basemodel.remove.calledOnce).to.be.true; - expect(mockLogger.error.notCalled).to.be.true; - }); - - it('logs an error and throws when remove fails', async () => { - const error = new Error('Remove failed'); - mockElectroService.entities.basemodel.remove.returns({ go: () => Promise.reject(error) }); - - await expect(baseModelInstance.remove()).to.be.rejectedWith('Remove failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('save', () => { - it('saves the record and returns the current instance', async () => { - baseModelInstance.patcher.save = stub().returns(Promise.resolve()); - await expect(baseModelInstance.save()).to.eventually.equal(baseModelInstance); - expect(baseModelInstance.patcher.save.calledOnce).to.be.true; - expect(mockLogger.error.notCalled).to.be.true; - }); - - it('logs an error and throws when save fails', async () => { - const error = new Error('Save failed'); - baseModelInstance.patcher.save = stub().returns(Promise.reject(error)); - - await expect(baseModelInstance.save()).to.be.rejectedWith('Save failed'); - expect(mockLogger.error.calledOnce).to.be.true; - }); - }); - - describe('_fetchReference', () => { /* eslint-disable no-underscore-dangle */ - it('returns a cached reference if it exists', async () => { - baseModelInstance._cacheReference('Foo', 'bar'); - const result = await baseModelInstance._fetchReference('has_many', 'Foo'); - expect(result).to.equal('bar'); - }); - - it('returns null if belongs_to id is not set', async () => { - const result = await baseModelInstance._fetchReference('belongs_to', 'Foo'); - expect(result).to.be.null; - }); - - it('returns undefined if the reference does not exist', async () => { - mockModelFactory.getCollection.returns({ findByIndexKeys: stub() }); - const result = await baseModelInstance._fetchReference('has_many', 'Foo'); - expect(result).to.be.undefined; - }); - - it('fetches a belongs_to reference by ID', async () => { - mockModelFactory.getCollection.returns({ findById: stub().returns('bar') }); - baseModelInstance.record.fooId = '12345'; - const result = await baseModelInstance._fetchReference('belongs_to', 'Foo'); - expect(result).to.equal('bar'); - }); - - it('fetches a has_one reference by ID', async () => { - mockModelFactory.getCollection.returns({ findById: stub().returns('bar') }); - baseModelInstance.record.fooId = '12345'; - const result = await baseModelInstance._fetchReference('has_one', 'Foo'); - expect(result).to.equal('bar'); - }); - - it('fetches a has_many reference by foreign key', async () => { - mockModelFactory.getCollection.returns({ findByIndexKeys: stub().returns(['bar']) }); - const result = await baseModelInstance._fetchReference('has_many', 'Foo'); - expect(result).to.deep.equal(['bar']); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.collection.test.js new file mode 100755 index 00000000..c2cef26c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.collection.test.js @@ -0,0 +1,651 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +// eslint-disable-next-line max-classes-per-file +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { ElectroValidationError } from 'electrodb'; +import { spy, stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import BaseCollection from '../../../../../src/v2/models/base/base.collection.js'; +import Schema from '../../../../../src/v2/models/base/schema.js'; +import BaseModel from '../../../../../src/v2/models/base/base.model.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const MockModel = class MockEntityModel extends BaseModel {}; +const MockCollection = class MockEntityCollection extends BaseCollection {}; + +const createSchema = (service, indexes) => new Schema( + MockModel, + MockCollection, + { + serviceName: 'service', + schemaVersion: 1, + attributes: { + someKey: { type: 'string' }, + someOtherKey: { type: 'number' }, + }, + indexes, + references: [], + }, +); + +const createInstance = (service, registry, indexes, log) => { + const schema = createSchema(service, indexes); + return new BaseCollection( + service, + registry, + schema, + log, + ); +}; + +describe('BaseCollection', () => { + let baseCollectionInstance; + let mockElectroService; + let mockEntityRegistry; + let mockIndexes = { primary: {} }; + let mockLogger; + + const mockRecord = { + mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956', + mockParentEntityModelId: 'some-parent-id', + data: { + someKey: 'someValue', + }, + }; + + beforeEach(() => { + mockEntityRegistry = { + getCollection: stub(), + }; + + mockLogger = { + error: spy(), + info: spy(), + warn: spy(), + }; + + mockElectroService = { + entities: { + mockEntityModel: { + create: stub(), + delete: stub(), + get: stub(), + put: stub(), + query: { + all: stub().returns({ + between: stub().returns({ + go: () => ({ data: [] }), + }), + go: () => ({ data: [] }), + }), + bySomeKey: stub(), + primary: stub(), + }, + model: { + entity: 'MockEntityModel', + indexes: {}, + table: 'data', + original: {}, + schema: { + attributes: {}, + }, + }, + }, + }, + }; + + baseCollectionInstance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + }); + + describe('collection methods', () => { + it('does not create accessors for the primary index', () => { + mockIndexes = { primary: {} }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + expect(instance).to.not.have.property('allBy'); + expect(instance).to.not.have.property('findBy'); + }); + + it('creates accessors for partition key attributes', () => { + mockIndexes = { + bySomeKey: { pk: { facets: ['someKey'] } }, + }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + expect(instance).to.have.property('allBySomeKey'); + expect(instance).to.have.property('findBySomeKey'); + }); + + it('creates accessors for sort key attributes', () => { + mockIndexes = { + bySomeKey: { sk: { facets: ['someKey'] } }, + }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + expect(instance).to.have.property('allBySomeKey'); + expect(instance).to.have.property('findBySomeKey'); + }); + + it('creates accessors for partition and sort key attributes', () => { + mockIndexes = { + bySomeKey: { pk: { facets: ['someKey'] }, sk: { facets: ['someOtherKey'] } }, + }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + expect(instance).to.have.property('allBySomeKey'); + expect(instance).to.have.property('allBySomeKeyAndSomeOtherKey'); + expect(instance).to.have.property('findBySomeKey'); + expect(instance).to.have.property('findBySomeKeyAndSomeOtherKey'); + }); + + it('parses accessor arguments correctly', async () => { + mockElectroService.entities.mockEntityModel.query.bySomeKey.returns( + { go: () => Promise.resolve({ data: [] }) }, + ); + mockIndexes = { + bySomeKey: { pk: { facets: ['someKey'] }, sk: { facets: ['someOtherKey'] } }, + }; + + mockElectroService.entities.mockEntityModel.model.schema = { + attributes: { + someKey: { type: 'string' }, + someOtherKey: { type: 'number' }, + }, + }; + + const instance = createInstance( + mockElectroService, + mockEntityRegistry, + mockIndexes, + mockLogger, + ); + + const someKey = 'someValue'; + const someOtherKey = 1; + const options = { order: 'desc' }; + + await instance.allBySomeKey(someKey); + await instance.findBySomeKey(someKey); + await instance.allBySomeKeyAndSomeOtherKey(someKey, someOtherKey); + await instance.findBySomeKeyAndSomeOtherKey(someKey, someOtherKey); + await instance.findBySomeKeyAndSomeOtherKey(someKey, someOtherKey, options); + + await expect(instance.allBySomeKey()).to.be.rejectedWith('someKey is required'); + await expect(instance.findBySomeKey()).to.be.rejectedWith('someKey is required'); + await expect(instance.allBySomeKeyAndSomeOtherKey(someKey)).to.be.rejectedWith('someOtherKey is required'); + await expect(instance.allBySomeKeyAndSomeOtherKey(someKey, '1')).to.be.rejectedWith('someOtherKey is required'); + await expect(instance.findBySomeKeyAndSomeOtherKey(someKey)).to.be.rejectedWith('someOtherKey is required'); + }); + }); + + describe('findById', () => { + it('returns the entity if found', async () => { + const mockFindResult = { data: mockRecord }; + mockElectroService.entities.mockEntityModel.get.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.findById('ef39921f-9a02-41db-b491-02c98987d956'); + + expect(result.record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.get.calledOnce).to.be.true; + }); + + it('returns null if the entity is not found', async () => { + mockElectroService.entities.mockEntityModel.get.returns( + { go: () => Promise.resolve(null) }, + ); + + const result = await baseCollectionInstance.findById('ef39921f-9a02-41db-b491-02c98987d956'); + + expect(result).to.be.null; + expect(mockElectroService.entities.mockEntityModel.get.calledOnce).to.be.true; + }); + }); + + describe('findByIndexKeys', () => { + it('throws error if keys is not provided', async () => { + await expect(baseCollectionInstance.findByIndexKeys()) + .to.be.rejectedWith('Failed to query [mockEntityModel]: keys are required'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('throws error if index is not found', async () => { + await expect(baseCollectionInstance.findByIndexKeys({ someKey: 'someValue' }, { index: 'none' })) + .to.be.rejectedWith('Failed to query [mockEntityModel]: index [none] not found'); + expect(mockLogger.error).to.have.been.calledOnce; + }); + }); + + describe('create', () => { + it('throws an error if the record is empty', async () => { + await expect(baseCollectionInstance.create(null)).to.be.rejectedWith('Failed to create [mockEntityModel]'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('creates a new entity successfully', async () => { + mockElectroService.entities.mockEntityModel.create.returns( + { go: () => Promise.resolve({ data: mockRecord }) }, + ); + + const result = await baseCollectionInstance.create(mockRecord); + expect(result.record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.create.calledOnce).to.be.true; + }); + + it('logs an error and throws when creation fails', async () => { + const error = new Error('Create failed'); + mockElectroService.entities.mockEntityModel.create.returns( + { go: () => Promise.reject(error) }, + ); + + await expect(baseCollectionInstance.create(mockRecord.data)).to.be.rejectedWith('Create failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('createMany', () => { + it('throws an error if the items are empty', async () => { + await expect(baseCollectionInstance.createMany(null)) + .to.be.rejectedWith('Failed to create many [mockEntityModel]: items must be a non-empty array'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('creates multiple entities successfully', async () => { + const mockRecords = [mockRecord, mockRecord]; + const mockPutResults = { + type: 'query', + method: 'batchWrite', + params: { + RequestItems: { + mockEntityModel: [ + { PutRequest: { Item: mockRecord } }, + { PutRequest: { Item: mockRecord } }, + ], + }, + }, + }; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve(mockPutResults), + params: () => ({ Item: { ...mockRecord } }), + }, + ); + + const result = await baseCollectionInstance.createMany(mockRecords); + expect(result.createdItems).to.be.an('array').that.has.length(2); + expect(result.createdItems[0].record).to.deep.include(mockRecord); + expect(result.createdItems[1].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.put.calledThrice).to.be.true; + }); + + it('creates many with a parent entity', async () => { + const mockRecords = [mockRecord, mockRecord]; + const mockPutResults = { + type: 'query', + method: 'batchWrite', + params: { + RequestItems: { + mockEntityModel: [ + { PutRequest: { Item: mockRecord } }, + { PutRequest: { Item: mockRecord } }, + ], + }, + }, + }; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve(mockPutResults), + params: () => ({ Item: { ...mockRecord } }), + }, + ); + + const parent = { + record: { mockParentEntityModelId: mockRecord.mockParentEntityModelId }, + entityName: 'mockParentEntityModel', + entity: { model: { name: 'mockParentEntityModel' } }, + schema: { getModelName: () => 'MockParentEntityModel' }, + }; + + const result = await baseCollectionInstance.createMany(mockRecords, parent); + + expect(result.createdItems).to.be.an('array').that.has.length(2); + expect(result.createdItems[0].record).to.deep.include(mockRecord); + expect(result.createdItems[1].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.put.calledThrice).to.be.true; + expect(mockLogger.warn).to.not.have.been.called; + }); + + it('logs warning if parent is invalid', async () => { + const mockRecords = [mockRecord, mockRecord]; + const mockPutResults = { + type: 'query', + method: 'batchWrite', + params: { + RequestItems: { + mockEntityModel: [ + { PutRequest: { Item: mockRecord } }, + { PutRequest: { Item: mockRecord } }, + ], + }, + }, + }; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve(mockPutResults), + params: () => ({ Item: { ...mockRecord } }), + }, + ); + + const idNotMatchingParent = { + record: { mockParentEntityModelId: 'invalid-id' }, + entityName: 'mockParentEntityModel', + entity: { model: { name: 'mockParentEntityModel' } }, + }; + + const noEntityParent = { + record: { mockParentEntityModelId: 'invalid-id' }, + entity: { model: { name: 'mockParentEntityModel' } }, + }; + + const r1 = await baseCollectionInstance.createMany(mockRecords, idNotMatchingParent); + const r2 = await baseCollectionInstance.createMany(mockRecords, noEntityParent); + + expect(r1.createdItems).to.be.an('array').that.has.length(2); + expect(r1.createdItems[0].record).to.deep.include(mockRecord); + expect(r1.createdItems[1].record).to.deep.include(mockRecord); + + expect(r2.createdItems).to.be.an('array').that.has.length(2); + expect(r2.createdItems[0].record).to.deep.include(mockRecord); + expect(r2.createdItems[1].record).to.deep.include(mockRecord); + + expect(mockElectroService.entities.mockEntityModel.put).to.have.callCount(6); + expect(mockLogger.warn).to.have.callCount(4); + }); + + it('creates some entities successfully with unprocessed items', async () => { + const mockRecords = [mockRecord, mockRecord]; + let itemCount = 0; + + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve({ unprocessed: [mockRecord] }), + params: () => { + if (itemCount === 0) { + itemCount += 1; + return { Item: { ...mockRecord } }; + } else { + throw new ElectroValidationError('Validation failed'); + } + }, + }, + ); + + const result = await baseCollectionInstance.createMany(mockRecords); + expect(result.createdItems).to.be.an('array').that.has.length(1); + expect(result.createdItems[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.put.calledThrice).to.be.true; + expect(mockLogger.error.calledOnceWith(`Failed to process all items in batch write for [mockEntityModel]: ${JSON.stringify([mockRecord])}`)).to.be.true; + }); + + it('fails creating some items due to ValidationError', async () => { + const error = new ElectroValidationError('Validation failed'); + mockElectroService.entities.mockEntityModel.put.returns( + { params: () => { throw error; } }, + ); + + const result = await baseCollectionInstance.createMany([mockRecord]); + expect(result.createdItems).to.be.an('array').that.has.length(0); + expect(result.errorItems).to.be.an('array').that.has.length(1); + expect(result.errorItems[0].item).to.deep.include(mockRecord); + }); + + it('logs an error and throws when creation fails', async () => { + const error = new Error('Create failed'); + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.reject(error), + params: () => ({ Item: { ...mockRecord } }), + }, + ); + + await expect(baseCollectionInstance.createMany(mockRecords)).to.be.rejectedWith('Create failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('_saveMany', () => { /* eslint-disable no-underscore-dangle */ + it('throws an error if the records are empty', async () => { + await expect(baseCollectionInstance._saveMany(null)) + .to.be.rejectedWith('Failed to save many [mockEntityModel]: items must be a non-empty array'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('saves multiple entities successfully', async () => { + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockEntityModel.put.returns({ go: () => [] }); + + const result = await baseCollectionInstance._saveMany(mockRecords); + expect(result).to.be.undefined; + expect(mockElectroService.entities.mockEntityModel.put.calledOnce).to.be.true; + }); + + it('saves some entities successfully with unprocessed items', async () => { + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockEntityModel.put.returns( + { + go: () => Promise.resolve({ unprocessed: [mockRecord] }), + }, + ); + + const result = await baseCollectionInstance._saveMany(mockRecords); + expect(result).to.be.undefined; + expect(mockElectroService.entities.mockEntityModel.put.calledOnce).to.be.true; + expect(mockLogger.error.calledOnceWith(`Failed to process all items in batch write for [mockEntityModel]: ${JSON.stringify([mockRecord])}`)).to.be.true; + }); + + it('throws error and logs when save fails', async () => { + const error = new Error('Save failed'); + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockEntityModel.put.returns( + { go: () => Promise.reject(error) }, + ); + + await expect(baseCollectionInstance._saveMany(mockRecords)).to.be.rejectedWith('Save failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('all', () => { + it('returns all entities successfully', async () => { + const mockFindResult = { data: [mockRecord] }; + mockElectroService.entities.mockEntityModel.query.all.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.all(); + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.all) + .to.have.been.calledOnceWithExactly({ pk: 'ALL_MOCKENTITYMODELS' }); + }); + + it('applies between filter if provided', async () => { + const mockFindResult = { data: [mockRecord] }; + const mockGo = stub().resolves(mockFindResult); + const mockBetween = stub().returns({ go: mockGo }); + mockElectroService.entities.mockEntityModel.query.all().between = mockBetween; + + const result = await baseCollectionInstance.all( + {}, + { between: { attribute: 'test', start: 'a', end: 'b' } }, + ); + + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockBetween).to.have.been.calledOnceWithExactly({ test: 'a' }, { test: 'b' }); + expect(mockGo).to.have.been.calledOnceWithExactly({ order: 'desc' }); + }); + + it('applies attribute filter if provided', async () => { + const mockFindResult = { data: [mockRecord] }; + const mockGo = stub().resolves(mockFindResult); + mockElectroService.entities.mockEntityModel.query.all.returns( + { go: mockGo }, + ); + + const result = await baseCollectionInstance.all({}, { attributes: ['test'] }); + + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.all) + .to.have.been.calledOnceWithExactly({ pk: 'ALL_MOCKENTITYMODELS' }); + expect(mockGo).to.have.been.calledOnceWithExactly({ order: 'desc', attributes: ['test'] }); + }); + }); + + describe('allByIndexKeys', () => { + it('throws error if keys is not provided', async () => { + await expect(baseCollectionInstance.allByIndexKeys()) + .to.be.rejectedWith('Failed to query [mockEntityModel]: keys are required'); + expect(mockLogger.error).to.have.been.calledOnce; + }); + + it('throws and error if options is not an object', async () => { + await expect(baseCollectionInstance.allByIndexKeys({ someKey: 'someValue' }, null)) + .to.be.rejectedWith('Failed to query [mockEntityModel]: options must be an object'); + expect(mockLogger.error).to.have.been.calledOnce; + }); + + it('successfully queries entities by index keys', async () => { + const mockFindResult = { data: [mockRecord] }; + mockElectroService.entities.mockEntityModel.query.bySomeKey.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.allByIndexKeys({ someKey: 'someValue' }); + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.bySomeKey) + .to.have.been.calledOnceWithExactly({ someKey: 'someValue' }); + }); + + it('successfully queries entities by primary index keys', async () => { + const mockFindResult = { data: [mockRecord] }; + delete mockElectroService.entities.mockEntityModel.query.all; + delete mockElectroService.entities.mockEntityModel.query.bySomeKey; + + mockElectroService.entities.mockEntityModel.query.primary.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.allByIndexKeys({ someKey: 'someValue' }); + expect(result).to.be.an('array').that.has.length(1); + expect(result[0].record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.primary) + .to.have.been.calledOnceWithExactly({ someKey: 'someValue' }); + }); + }); + + describe('findByAll', () => { + it('throws an error if sortKeys is not an object', async () => { + await expect(baseCollectionInstance.findByAll(null)) + .to.be.rejectedWith('Failed to find by all [mockEntityModel]: sort keys must be an object'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('finds all entities successfully', async () => { + const mockFindResult = { data: [mockRecord] }; + mockElectroService.entities.mockEntityModel.query.all.returns( + { go: () => Promise.resolve(mockFindResult) }, + ); + + const result = await baseCollectionInstance.findByAll({ someKey: 'someValue' }); + expect(result.record).to.deep.include(mockRecord); + expect(mockElectroService.entities.mockEntityModel.query.all) + .to.have.been.calledOnceWithExactly( + { pk: 'ALL_MOCKENTITYMODELS', someKey: 'someValue' }, + ); + }); + + it('returns null if the entity is not found', async () => { + const result = await baseCollectionInstance.findByAll({ someKey: 'someValue' }); + expect(result).to.be.null; + expect(mockElectroService.entities.mockEntityModel.query.all) + .to.have.been.calledOnceWithExactly( + { pk: 'ALL_MOCKENTITYMODELS', someKey: 'someValue' }, + ); + }); + }); + + describe('removeByIds', () => { + it('throws an error if the ids are not an array', async () => { + await expect(baseCollectionInstance.removeByIds(null)) + .to.be.rejectedWith('Failed to remove [mockEntityModel]: ids must be a non-empty array'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('throws an error if the ids are empty', async () => { + await expect(baseCollectionInstance.removeByIds([])) + .to.be.rejectedWith('Failed to remove [mockEntityModel]: ids must be a non-empty array'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('removes entities successfully', async () => { + const mockIds = ['ef39921f-9a02-41db-b491-02c98987d956', 'ef39921f-9a02-41db-b491-02c98987d957']; + mockElectroService.entities.mockEntityModel.delete.returns({ go: () => Promise.resolve() }); + await baseCollectionInstance.removeByIds(mockIds); + expect(mockElectroService.entities.mockEntityModel.delete) + .to.have.been.calledOnceWithExactly([ + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d956' }, + { mockEntityModelId: 'ef39921f-9a02-41db-b491-02c98987d957' }, + ]); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.model.test.js new file mode 100755 index 00000000..1c6be4e6 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/base.model.test.js @@ -0,0 +1,309 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { Entity } from 'electrodb'; +import { spy, stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import BaseModel from '../../../../../src/v2/models/base/base.model.js'; +import KeyEventSchema from '../../../../../src/v2/models/key-event/key-event.schema.js'; +import OpportunitySchema from '../../../../../src/v2/models/opportunity/opportunity.schema.js'; +import SuggestionSchema from '../../../../../src/v2/models/suggestion/suggestion.schema.js'; +import Reference from '../../../../../src/v2/models/base/reference.js'; +import BaseCollection from '../../../../../src/v2/models/base/base.collection.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const opportunityEntity = new Entity(OpportunitySchema.toElectroDBSchema()); +const suggestionEntity = new Entity(SuggestionSchema.toElectroDBSchema()); +const MockCollection = class MockCollection extends BaseCollection {}; + +describe('BaseModel', () => { + let mockElectroService; + let baseModelInstance; + let mockLogger; + let mockEntityRegistry; + + const mockRecord = { + opportunityId: '12345', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + beforeEach(() => { + mockLogger = { + debug: spy(), + error: spy(), + info: spy(), + warn: spy(), + }; + + mockEntityRegistry = { + log: mockLogger, + getCollection: stub().returns({ + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: stub().returns('Opportunity'), + }, + }), + }; + + mockEntityRegistry.getCollection.withArgs('OpportunityCollection').returns({ + log: mockLogger, + findByIndexKeys: stub().resolves({}), + allByIndexKeys: stub().resolves([]), + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: stub().returns('Opportunity'), + }, + }); + + mockElectroService = { + entities: { + opportunity: { + entity: opportunityEntity, + remove: stub().returns({ go: stub().resolves() }), + }, + suggestion: { + entity: suggestionEntity, + query: { primary: stub().returns({ go: stub().resolves({ data: [mockRecord] }) }) }, + remove: stub().returns({ go: stub().resolves() }), + indexes: { + primary: {}, + }, + }, + }, + }; + + const SuggestionCollection = new MockCollection( + mockElectroService, + mockEntityRegistry, + SuggestionSchema, + mockLogger, + ); + + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(SuggestionCollection); + + baseModelInstance = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + mockRecord, + mockLogger, + ); + }); + + describe('base', () => { + it('creates a new instance of BaseModel', () => { + expect(baseModelInstance).to.be.an.instanceOf(BaseModel); + }); + + it('returns when initializeAttributes has no attributes', () => { + const originalAttributes = { ...OpportunitySchema.attributes }; + OpportunitySchema.attributes = {}; + + const instance = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + {}, + mockLogger, + ); + + expect(instance).to.be.an.instanceOf(BaseModel); + + OpportunitySchema.attributes = originalAttributes; + }); + }); + + describe('getId', () => { + it('returns the ID of the entity', () => { + const id = baseModelInstance.getId(); + expect(id).to.equal('12345'); + }); + }); + + describe('getCreatedAt', () => { + it('returns the creation timestamp in ISO format', () => { + const createdAt = baseModelInstance.getCreatedAt(); + expect(createdAt).to.equal(mockRecord.createdAt); + }); + }); + + describe('getUpdatedAt', () => { + it('returns the updated timestamp in ISO format', () => { + const updatedAt = baseModelInstance.getUpdatedAt(); + expect(updatedAt).to.equal(mockRecord.updatedAt); + }); + }); + + describe('remove', () => { + let dependent; + let dependents; + let schema; + let originalReferences = []; + + beforeEach(() => { + dependent = { remove: stub().resolves() }; + dependents = [dependent, dependent, dependent]; + originalReferences = [...OpportunitySchema.references]; + schema = OpportunitySchema; + + const collectionMethods = { + findByIndexKeys: stub().resolves(dependent), + allByIndexKeys: stub().resolves(dependents), + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + }, + }; + + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(collectionMethods); + mockEntityRegistry.getCollection.withArgs('SomeModelCollection').returns(collectionMethods); + mockElectroService.entities.opportunity.remove.returns({ go: () => Promise.resolve() }); + }); + + afterEach(() => { + OpportunitySchema.references = originalReferences; + }); + + it('removes the record and returns the current instance', async () => { + await expect(baseModelInstance.remove()).to.eventually.equal(baseModelInstance); + + expect(mockElectroService.entities.opportunity.remove.calledOnce).to.be.true; + expect(mockLogger.error.notCalled).to.be.true; + }); + + it('removes record with dependents', async () => { + const reference = Reference.fromJSON({ + type: Reference.TYPES.HAS_ONE, + target: 'SomeModel', + options: { removeDependents: true }, + }); + + baseModelInstance.getSomeModel = stub().resolves(dependent); + baseModelInstance.getSuggestions = stub().resolves(dependents); + + schema.references.push(reference); + + await expect(baseModelInstance.remove()).to.eventually.equal(baseModelInstance); + + // self remove + expect(mockElectroService.entities.opportunity.remove.calledOnce).to.be.true; + // dependents remove: 3 = has_many, 1 = has_one + expect(dependent.remove).to.have.callCount(4); + expect(baseModelInstance.getSomeModel).to.have.been.calledOnce; + expect(mockLogger.error).to.not.have.been.called; + }); + + it('does not remove dependents if there aren\'t any', async () => { + schema.references = []; + + await expect(baseModelInstance.remove()).to.eventually.equal(baseModelInstance); + + expect(dependent.remove.notCalled).to.be.true; + }); + + it('does not remove dependents if none are found', async () => { + schema.references[0].options.removeDependents = true; + schema.references[1].options.removeDependents = true; + mockEntityRegistry.getCollection = () => ({ + allByIndexKeys: stub().resolves([]), + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: stub().returns('SomeModel'), + }, + }); + + const instance = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + mockRecord, + mockLogger, + ); + + await expect(instance.remove()).to.eventually.equal(instance); + + expect(dependent.remove.notCalled).to.be.true; + }); + + it('logs an error and throws when remove fails', async () => { + const error = new Error('Remove failed'); + mockElectroService.entities.opportunity.remove.returns({ go: () => Promise.reject(error) }); + + await expect(baseModelInstance.remove()).to.be.rejectedWith('Remove failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('save', () => { + it('saves the record and returns the current instance', async () => { + baseModelInstance.patcher.save = stub().returns(Promise.resolve()); + await expect(baseModelInstance.save()).to.eventually.equal(baseModelInstance); + expect(baseModelInstance.patcher.save.calledOnce).to.be.true; + expect(mockLogger.error.notCalled).to.be.true; + }); + + it('logs an error and throws when save fails', async () => { + const error = new Error('Save failed'); + baseModelInstance.patcher.save = stub().returns(Promise.reject(error)); + + await expect(baseModelInstance.save()).to.be.rejectedWith('Save failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); + + describe('references', () => { /* eslint-disable no-underscore-dangle */ + describe('reciprocal', () => { + it('logs a warning if reference is not found', async () => { + mockEntityRegistry.getCollection.withArgs('FooCollection').returns(new MockCollection( + mockElectroService, + mockEntityRegistry, + KeyEventSchema, + mockLogger, + )); + OpportunitySchema.references.push(new Reference('has_many', 'Foos')); + + const result = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + mockRecord, + mockLogger, + ); + + expect(result).to.be.an.instanceOf(BaseModel); + expect(mockLogger.warn).to.have.been.calledOnceWithExactly('Reciprocal reference not found for Opportunity to Foos'); + }); + + it('logs a debug message if reference sort keys are empty', async () => { + SuggestionSchema.references = [new Reference('belongs_to', 'Opportunity', { sortKeys: [] })]; + + const result = new BaseModel( + mockElectroService, + mockEntityRegistry, + OpportunitySchema, + mockRecord, + mockLogger, + ); + + expect(result).to.be.an.instanceOf(BaseModel); + expect(mockLogger.debug).to.have.been.calledOnceWithExactly('No sort keys defined for Opportunity to Suggestions'); + }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/entity.registry.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/entity.registry.test.js new file mode 100644 index 00000000..bf7c5437 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/entity.registry.test.js @@ -0,0 +1,114 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +// eslint-disable-next-line max-classes-per-file +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import EntityRegistry from '../../../../../src/v2/models/base/entity.registry.js'; +import { BaseCollection, BaseModel } from '../../../../../src/index.js'; +import Schema from '../../../../../src/v2/models/base/schema.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('EntityRegistry', () => { + const MockModel = class MockModel extends BaseModel {}; + const MockCollection = class MockCollection extends BaseCollection {}; + const MockSchema = new Schema( + MockModel, + MockCollection, + { + attributes: { test: {} }, + indexes: { test: {} }, + serviceName: 'SpaceDog', + schemaVersion: 1, + references: [], + }, + ); + + let electroService; + let entityRegistry; + let originalEntities; + + beforeEach(() => { + originalEntities = { ...EntityRegistry.entities }; + EntityRegistry.entities = {}; + + electroService = { + entities: { + mockModel: { + model: { + name: 'test', + indexes: [], + schema: {}, + original: { + references: {}, + }, + }, + }, + }, + }; + + EntityRegistry.registerEntity(MockSchema, MockCollection); + + entityRegistry = new EntityRegistry(electroService, console); + }); + + afterEach(() => { + EntityRegistry.entities = originalEntities; + }); + + it('gets collection by collection name', () => { + const collection = entityRegistry.getCollection('MockCollection'); + + expect(collection).to.be.an.instanceOf(BaseCollection); + }); + + it('throws error when getting a non-existing collection', () => { + expect(() => entityRegistry.getCollection('NonExistentCollection')) + .to.throw('Collection NonExistentCollection not found'); + }); + + it('gets all collections', () => { + const collections = entityRegistry.getCollections(); + + expect(collections).to.be.an('object'); + expect(Object.keys(collections)).to.have.lengthOf(1); + expect(collections.Mock).to.be.an.instanceOf(MockCollection); + }); + + it('gets all entities', () => { + const entities = EntityRegistry.getEntities(); + + expect(entities).to.be.an('object'); + expect(Object.keys(entities)).to.have.lengthOf(1); + expect(entities).to.deep.equal({ + mockModel: { + attributes: { + test: {}, + }, + indexes: { + test: {}, + }, + model: { + entity: 'MockModel', + service: 'SpaceDog', + version: '1', + }, + }, + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/reference.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/reference.test.js new file mode 100644 index 00000000..a618c26d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/reference.test.js @@ -0,0 +1,312 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import { stub } from 'sinon'; +import Reference from '../../../../../src/v2/models/base/reference.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('Reference', () => { + let mockLogger; + + beforeEach(() => { + mockLogger = { + debug: stub(), + error: stub(), + warn: stub(), + }; + }); + + describe('constructor', () => { + it('creates a new reference with the correct properties', () => { + const reference = new Reference('has_many', 'Test'); + + expect(reference).to.be.an('object'); + expect(reference).to.deep.equal({ + options: {}, + target: 'Test', + type: 'has_many', + }); + }); + + it('creates a new reference from JSON', () => { + const reference = Reference.fromJSON({ + options: {}, + target: 'Test', + type: 'has_many', + }); + + expect(reference).to.be.an('object'); + expect(reference).to.deep.equal({ + options: {}, + target: 'Test', + type: 'has_many', + }); + }); + + it('throws an error for an invalid type', () => { + expect(() => new Reference('invalid', 'Test')).to.throw('Invalid reference type: invalid'); + }); + + it('throws an error for an invalid target', () => { + expect(() => new Reference('has_many', '')).to.throw('Invalid target'); + }); + }); + + describe('isValidType', () => { + it('returns true for a valid type', () => { + expect(Reference.isValidType('has_many')).to.be.true; + }); + + it('returns false for an invalid type', () => { + expect(Reference.isValidType('invalid')).to.be.false; + }); + }); + + describe('accessors', () => { + it('returns the target', () => { + const reference = new Reference('has_many', 'Test'); + + expect(reference.getTarget()).to.equal('Test'); + }); + + it('returns the type', () => { + const reference = new Reference('has_many', 'Test'); + + expect(reference.getType()).to.equal('has_many'); + }); + + it('returns true for removeDependents', () => { + const reference = new Reference('has_many', 'Test', { removeDependents: true }); + + expect(reference.isRemoveDependents()).to.be.true; + }); + + it('returns false for removeDependents', () => { + const reference = new Reference('has_many', 'Test', { removeDependents: false }); + + expect(reference.isRemoveDependents()).to.be.false; + }); + }); + + describe('toAccessorConfigs', () => { + it('returns accessor configs for has_many', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(new Reference('belongs_to', 'Test')), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('has_many', 'Test'); + const entity = { + entityName: 'Test', + getId: () => '123', + schema, + }; + + const accessorConfigs = reference.toAccessorConfigs(registry, entity); + + expect(accessorConfigs).to.be.an('array'); + expect(accessorConfigs).to.have.lengthOf(1); + expect(accessorConfigs[0]).to.deep.equal({ + all: true, + collection: { + name: 'TestCollection', + schema, + }, + context: { + entityName: 'Test', + getId: entity.getId, + schema, + }, + foreignKey: { + name: 'testId', + value: '123', + }, + name: 'getTests', + requiredKeys: [], + }); + }); + + it('returns accessor configs for has_one', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(new Reference('belongs_to', 'Test')), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('has_one', 'Test'); + const entity = { + entityName: 'Test', + getId: () => '123', + schema, + }; + + const accessorConfigs = reference.toAccessorConfigs(registry, entity); + + expect(accessorConfigs).to.be.an('array'); + expect(accessorConfigs).to.have.lengthOf(1); + expect(accessorConfigs[0]).to.deep.equal({ + collection: { + name: 'TestCollection', + schema, + }, + context: { + entityName: 'Test', + getId: entity.getId, + schema, + }, + foreignKey: { + name: 'testId', + value: '123', + }, + name: 'getTest', + requiredKeys: [], + }); + }); + + it('returns accessor configs for belongs_to', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(new Reference('belongs_to', 'Test')), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('belongs_to', 'Test'); + const entity = { + entityName: 'Test', + record: { testId: '123' }, + schema, + }; + + const accessorConfigs = reference.toAccessorConfigs(registry, entity); + + expect(accessorConfigs).to.be.an('array'); + expect(accessorConfigs).to.have.lengthOf(1); + expect(accessorConfigs[0]).to.deep.equal({ + collection: { + name: 'TestCollection', + schema, + }, + context: { + entityName: 'Test', + record: { testId: '123' }, + schema, + }, + foreignKey: { + name: 'testId', + value: '123', + }, + byId: true, + name: 'getTest', + requiredKeys: [], + }); + }); + + it('logs warning for missing reciprocal reference', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('has_many', 'Test'); + const entity = { + entityName: 'Test', + getId: () => '123', + schema, + }; + + reference.toAccessorConfigs(registry, entity); + + expect(mockLogger.warn).to.have.been.calledOnceWithExactly('Reciprocal reference not found for Test to Test'); + }); + + it('logs debug for no sort keys defined', () => { + const schema = { + getReferenceByTypeAndTarget: stub().returns(new Reference('belongs_to', 'Test')), + getModelName: () => 'Test', + }; + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema, + }), + }; + const reference = new Reference('has_many', 'Test'); + const entity = { + entityName: 'Test', + getId: () => '123', + schema, + }; + + reference.toAccessorConfigs(registry, entity); + + expect(mockLogger.debug).to.have.been.calledOnceWithExactly('No sort keys defined for Test to Test'); + }); + + it('throws an error for an invalid type', () => { + const reference = new Reference('has_many', 'Test'); + reference.type = 'invalid'; + + const registry = { + log: mockLogger, + getCollection: stub().returns({ + name: 'TestCollection', + schema: {}, + }), + }; + + expect(() => reference.toAccessorConfigs(registry, { })).to.throw('Unsupported reference type: invalid'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.builder.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.builder.test.js new file mode 100755 index 00000000..76b63c2b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.builder.test.js @@ -0,0 +1,475 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +// eslint-disable-next-line max-classes-per-file +import { isIsoDate } from '@adobe/spacecat-shared-utils'; +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import { validate as uuidValidate } from 'uuid'; + +import SchemaBuilder from '../../../../../src/v2/models/base/schema.builder.js'; +import { BaseCollection, BaseModel } from '../../../../../src/index.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SchemaBuilder', () => { + const MockModel = class MockModel extends BaseModel {}; + const MockCollection = class MockCollection extends BaseCollection {}; + + let instance; + + beforeEach(() => { + instance = new SchemaBuilder(MockModel, MockCollection); + }); + + describe('constructor', () => { + it('throws error if invalid model class is provided', () => { + expect(() => new SchemaBuilder()) + .to.throw('modelClass must be a subclass of BaseModel.'); + expect(() => new SchemaBuilder(Number)) + .to.throw('modelClass must be a subclass of BaseModel.'); + }); + + it('throws error if invalid collection class is provided', () => { + expect(() => new SchemaBuilder(MockModel)) + .to.throw('collectionClass must be a subclass of BaseCollection.'); + expect(() => new SchemaBuilder(MockModel, Number)) + .to.throw('collectionClass must be a subclass of BaseCollection.'); + }); + + it('throws an error if version is not a positive integer', () => { + expect(() => new SchemaBuilder(MockModel, MockCollection, -1)) + .to.throw('schemaVersion is required and must be a positive integer.'); + expect(() => new SchemaBuilder(MockModel, MockCollection, '-1')) + .to.throw('schemaVersion is required and must be a positive integer.'); + expect(() => new SchemaBuilder(MockModel, MockCollection, 1.2)) + .to.throw('schemaVersion is required and must be a positive integer.'); + }); + + it('successfully creates an instance', () => { + expect(instance).to.be.an.instanceOf(SchemaBuilder); + expect(instance.entityName).to.equal('MockModel'); + expect(instance.serviceName).to.equal('SpaceCat'); + expect(instance.schemaVersion).to.equal(1); + expect(instance.indexes).to.deep.equal({}); + expect(instance.references).to.deep.equal([]); + expect(instance.attributes).to.deep.equal({ + mockModelId: { + default: instance.attributes.mockModelId.default, + type: 'string', + required: true, + readOnly: true, + validate: instance.attributes.mockModelId.validate, + }, + createdAt: { + default: instance.attributes.createdAt.default, + type: 'string', + readOnly: true, + required: true, + }, + updatedAt: { + default: instance.attributes.updatedAt.default, + type: 'string', + required: true, + readOnly: true, + watch: '*', + set: instance.attributes.updatedAt.set, + }, + }); + + expect(instance.rawIndexes).to.deep.equal({ + primary: { + pk: { composite: ['mockModelId'], field: 'pk' }, + sk: { composite: [], field: 'sk' }, + }, + all: null, + belongs_to: {}, + other: {}, + }); + }); + }); + + describe('addAttribute', () => { + it('throws error if attribute name is not provided', () => { + expect(() => instance.addAttribute()) + .to.throw('Attribute name is required and must be non-empty.'); + }); + + it('throws error if attribute definition is not provided', () => { + expect(() => instance.addAttribute('test')) + .to.throw('Attribute data for "test" is required and must be a non-empty object.'); + expect(() => instance.addAttribute('test', 'test')) + .to.throw('Attribute data for "test" is required and must be a non-empty object.'); + expect(() => instance.addAttribute('test', {})) + .to.throw('Attribute data for "test" is required and must be a non-empty object.'); + }); + + it('successfully adds an attribute', () => { + const result = instance.addAttribute('test', { + type: 'string', + required: true, + default: 'test', + validate: () => true, + }); + + expect(result).to.equal(instance); + expect(instance.attributes.test).to.deep.equal({ + type: 'string', + required: true, + default: 'test', + validate: instance.attributes.test.validate, + }); + }); + }); + + describe('addAllIndexWithComposite', () => { + it('throws error if attribute name is not provided', () => { + expect(() => instance.addAllIndexWithComposite()) + .to.throw('At least one composite attribute name is required.'); + }); + + it('successfully adds an all index', () => { + const result = instance.addAllIndexWithComposite('test'); + + expect(result).to.equal(instance); + expect(instance.rawIndexes.all).to.deep.equal({ + index: 'spacecat-data-MockModel-all', + pk: { field: 'gsi1pk', template: 'ALL_MOCKMODELS' }, + sk: { composite: ['test'], field: 'gsi1sk' }, + }); + }); + }); + + describe('addAllIndexWithTemplateField', () => { + it('throws error if field name is not provided', () => { + expect(() => instance.addAllIndexWithTemplateField()) + .to.throw('fieldName is required and must be a non-empty string.'); + }); + + it('throws error if template is not provided', () => { + expect(() => instance.addAllIndexWithTemplateField('test')) + .to.throw('template is required and must be a non-empty string.'); + }); + + it('successfully adds an all index', () => { /* eslint-disable no-template-curly-in-string */ + const result = instance.addAllIndexWithTemplateField('test', '${test}'); + + expect(result).to.equal(instance); + expect(instance.rawIndexes.all).to.deep.equal({ + index: 'spacecat-data-MockModel-all', + pk: { field: 'gsi1pk', template: 'ALL_MOCKMODELS' }, + sk: { field: 'test', template: '${test}' }, + }); + }); + }); + + describe('addIndex', () => { + it('throws error if index name is not provided', () => { + expect(() => instance.addIndex()) + .to.throw('Index name is required and must be a non-empty string.'); + }); + + it('throws error if index name is reserved', () => { + expect(() => instance.addIndex('all')) + .to.throw('Index name "all" is reserved.'); + expect(() => instance.addIndex('primary')) + .to.throw('Index name "primary" is reserved.'); + }); + + it('throws error if pk is not provided', () => { + expect(() => instance.addIndex('test')) + .to.throw('Partition key configuration (pk) is required and must be a non-empty object.'); + expect(() => instance.addIndex('test', 'pk')) + .to.throw('Partition key configuration (pk) is required and must be a non-empty object.'); + expect(() => instance.addIndex('test', {})) + .to.throw('Partition key configuration (pk) is required and must be a non-empty object.'); + }); + + it('throws error if sk is not provided', () => { + expect(() => instance.addIndex('test', { composite: ['test'] })) + .to.throw('Sort key configuration (sk) is required and must be a non-empty object.'); + expect(() => instance.addIndex('test', { composite: ['test'] }, 'sk')) + .to.throw('Sort key configuration (sk) is required and must be a non-empty object.'); + expect(() => instance.addIndex('test', { composite: ['test'] }, {})) + .to.throw('Sort key configuration (sk) is required and must be a non-empty object.'); + }); + + it('successfully adds an index', () => { + const result = instance.addIndex('test', { composite: ['test'] }, { composite: ['test'] }); + + expect(result).to.equal(instance); + expect(instance.rawIndexes.other.test).to.deep.equal({ + index: 'spacecat-data-MockModel-test', + pk: { composite: ['test'] }, + sk: { composite: ['test'] }, + }); + }); + }); + + describe('addReference', () => { + it('throws error if reference type is not provided', () => { + expect(() => instance.addReference()) + .to.throw('Invalid referenceType: "undefined"'); + }); + + it('throws error if reference type is invalid', () => { + expect(() => instance.addReference('test')) + .to.throw('Invalid referenceType: "test"'); + }); + + it('throws error if entity name is not provided', () => { + expect(() => instance.addReference('belongs_to')) + .to.throw('entityName for reference is required and must be a non-empty string.'); + }); + + it('successfully adds a has_many reference', () => { + const result = instance.addReference('has_many', 'SomeEntity'); + + expect(result).to.equal(instance); + expect(instance.references).to.be.an('array').with.length(1); + expect(instance.references[0]) + .to.deep.equal({ + options: { + removeDependents: false, + sortKeys: [], + }, + target: 'SomeEntity', + type: 'has_many', + }); + expect(instance.attributes).to.not.have.property('someEntityId'); + expect(instance.rawIndexes.belongs_to).to.not.have.property('bySomeEntityId'); + }); + + it('successfully adds a has_many reference with removeDependents', () => { + const result = instance.addReference('has_many', 'SomeEntity', [], { removeDependents: true }); + + expect(result).to.equal(instance); + expect(instance.references).to.be.an('array').with.length(1); + expect(instance.references[0]).to.deep.equal({ + options: { + removeDependents: true, + sortKeys: [], + }, + target: 'SomeEntity', + type: 'has_many', + }); + expect(instance.attributes).to.not.have.property('someEntityId'); + expect(instance.rawIndexes.belongs_to).to.not.have.property('bySomeEntityId'); + }); + + it('successfully adds a belongs_to reference', () => { + const result = instance.addReference('belongs_to', 'SomeEntity'); + + expect(result).to.equal(instance); + expect(instance.references).to.be.an('array').with.length(1); + expect(instance.references[0]).to.deep.equal({ + options: { + required: true, + sortKeys: [], + }, + target: 'SomeEntity', + type: 'belongs_to', + }); + expect(instance.attributes.someEntityId).to.deep.equal({ + required: true, + type: 'string', + validate: instance.attributes.someEntityId.validate, + }); + expect(instance.rawIndexes.belongs_to.bySomeEntityId).to.deep.equal({ + index: 'spacecat-data-MockModel-bySomeEntityId', + pk: { composite: ['someEntityId'] }, + sk: { composite: ['updatedAt'] }, + }); + }); + + it('successfully adds a belongs_to reference which is not required', () => { + const result = instance.addReference('belongs_to', 'someEntity', ['updatedAt'], { required: false }); + + expect(result).to.equal(instance); + expect(instance.references).to.be.an('array').with.length(1); + expect(instance.references[0]).to.deep.equal({ + options: { + required: false, + sortKeys: ['updatedAt'], + }, + target: 'someEntity', + type: 'belongs_to', + }); + expect(instance.attributes.someEntityId).to.deep.equal({ + required: false, + type: 'string', + validate: instance.attributes.someEntityId.validate, + }); + expect(instance.rawIndexes.belongs_to.bySomeEntityId).to.deep.equal({ + index: 'spacecat-data-MockModel-bySomeEntityId', + pk: { composite: ['someEntityId'] }, + sk: { composite: ['updatedAt'] }, + }); + }); + }); + + describe('validate, default, and set', () => { + it('sets defaults for createdAt and updatedAt', () => { + expect(isIsoDate(instance.attributes.createdAt.default())).to.be.true; + expect(isIsoDate(instance.attributes.updatedAt.default())).to.be.true; + expect(isIsoDate(instance.attributes.updatedAt.set())).to.be.true; + }); + + it('sets default for id attribute', () => { + expect(uuidValidate(instance.attributes.mockModelId.default())).to.be.true; + }); + + it('validates id attribute', () => { + expect(instance.attributes.mockModelId.validate('78fec9c7-2141-4600-b7b1-ea5c78752b91')).to.be.true; + expect(instance.attributes.mockModelId.validate('invalid')).to.be.false; + }); + + it('validates foreign key attribute', () => { + instance.addReference('belongs_to', 'someEntity'); + expect(instance.attributes.someEntityId.validate('78fec9c7-2141-4600-b7b1-ea5c78752b91')).to.be.true; + expect(instance.attributes.someEntityId.validate('invalid')).to.be.false; + }); + + it('validates non-required foreign key attribute', () => { + instance.addReference('belongs_to', 'someEntity', [], { required: false }); + expect(instance.attributes.someEntityId.required).to.be.false; + expect(instance.attributes.someEntityId.validate()).to.be.true; + expect(instance.attributes.someEntityId.validate('78fec9c7-2141-4600-b7b1-ea5c78752b91')).to.be.true; + expect(instance.attributes.someEntityId.validate('invalid')).to.be.false; + }); + }); + + describe('build', () => { + it('returns the built schema', () => { + instance.addReference('belongs_to', 'Organization'); + instance.addReference('belongs_to', 'Site', ['someField'], { required: false }); + instance.addReference('has_many', 'Audits'); + instance.addAttribute('baseURL', { + type: 'string', + required: true, + validate: () => true, + }); + instance.addAllIndexWithComposite('baseURL'); + instance.addAllIndexWithTemplateField('test', '${test}'); + instance.addIndex('byDeliveryType', { composite: ['deliveryType'] }, { composite: ['updatedAt'] }); + instance.addIndex('bySomeField', { field: 'someField', composite: ['deliveryType'] }, { composite: ['updatedAt'] }); + + const schema = instance.build(); + + expect(schema).to.deep.equal({ + schemaVersion: 1, + serviceName: 'SpaceCat', + modelClass: MockModel, + collectionClass: MockCollection, + attributes: { + mockModelId: { + type: 'string', + required: true, + readOnly: true, + validate: instance.attributes.mockModelId.validate, + default: instance.attributes.mockModelId.default, + }, + createdAt: { + type: 'string', + readOnly: true, + required: true, + default: instance.attributes.createdAt.default, + }, + updatedAt: { + type: 'string', + required: true, + readOnly: true, + watch: '*', + default: instance.attributes.updatedAt.default, + set: instance.attributes.updatedAt.set, + }, + organizationId: { + type: 'string', + required: true, + validate: instance.attributes.organizationId.validate, + }, + siteId: { + type: 'string', + required: false, + validate: instance.attributes.siteId.validate, + }, + baseURL: { + type: 'string', + required: true, + validate: instance.attributes.baseURL.validate, + }, + }, + indexes: { + primary: { + pk: { field: 'pk', composite: ['mockModelId'] }, + sk: { field: 'sk', composite: [] }, + }, + all: { + index: 'spacecat-data-MockModel-all', + pk: { field: 'gsi1pk', template: 'ALL_MOCKMODELS' }, + sk: { field: 'test', template: '${test}' }, + }, + byOrganizationId: { + index: 'spacecat-data-MockModel-byOrganizationId', + pk: { composite: ['organizationId'], field: 'gsi2pk' }, + sk: { composite: ['updatedAt'], field: 'gsi2sk' }, + }, + bySiteId: { + index: 'spacecat-data-MockModel-bySiteId', + pk: { composite: ['siteId'], field: 'gsi3pk' }, + sk: { composite: ['someField'], field: 'gsi3sk' }, + }, + byDeliveryType: { + index: 'spacecat-data-MockModel-byDeliveryType', + pk: { composite: ['deliveryType'], field: 'gsi4pk' }, + sk: { composite: ['updatedAt'], field: 'gsi4sk' }, + }, + bySomeField: { + index: 'spacecat-data-MockModel-bySomeField', + pk: { composite: ['deliveryType'], field: 'someField' }, + sk: { composite: ['updatedAt'], field: 'gsi5sk' }, + }, + }, + references: [ + { + options: { + required: true, + sortKeys: [], + }, + target: 'Organization', + type: 'belongs_to', + }, + { + options: { + required: false, + sortKeys: ['someField'], + }, + target: 'Site', + type: 'belongs_to', + }, + { + options: { + removeDependents: false, + sortKeys: [], + }, + target: 'Audits', + type: 'has_many', + }, + ], + }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.test.js new file mode 100644 index 00000000..eb3c643d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base/schema.test.js @@ -0,0 +1,229 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +// eslint-disable-next-line max-classes-per-file +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import BaseModel from '../../../../../src/v2/models/base/base.model.js'; +import BaseCollection from '../../../../../src/v2/models/base/base.collection.js'; +import Schema from '../../../../../src/v2/models/base/schema.js'; +import Reference from '../../../../../src/v2/models/base/reference.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const MockModel = class MockEntityModel extends BaseModel {}; +const MockCollection = class MockEntityCollection extends BaseCollection {}; + +describe('Schema', () => { + const rawSchema = { + serviceName: 'service', + schemaVersion: 1, + attributes: { + id: { type: 'string' }, + }, + indexes: { + primary: { pk: { composite: ['id'] } }, + byOrganizationId: { sk: { facets: ['organizationId'] } }, + }, + references: [new Reference('belongs_to', 'Organization')], + }; + + let instance; + + beforeEach(() => { + instance = new Schema(MockModel, MockCollection, rawSchema); + }); + + describe('constructor', () => { + it('constructs a new Schema instance', () => { + const schema = new Schema(MockModel, MockCollection, rawSchema); + + expect(schema.modelClass).to.equal(MockModel); + expect(schema.collectionClass).to.equal(MockCollection); + expect(schema.serviceName).to.equal('service'); + expect(schema.schemaVersion).to.equal(1); + expect(schema.attributes).to.deep.equal({ id: { type: 'string' } }); + expect(schema.indexes).to.deep.equal(rawSchema.indexes); + expect(schema.references).to.deep.equal([{ + options: {}, + target: 'Organization', + type: 'belongs_to', + }]); + }); + + it('throws an error if modelClass does not extend BaseModel', () => { + expect(() => new Schema({}, MockCollection, rawSchema)).to.throw('Model class must extend BaseModel'); + expect(() => new Schema(String, MockCollection, rawSchema)).to.throw('Model class must extend BaseModel'); + }); + + it('throws an error if collectionClass does not extend BaseCollection', () => { + expect(() => new Schema(MockModel, {}, rawSchema)).to.throw('Collection class must extend BaseCollection'); + expect(() => new Schema(MockModel, String, rawSchema)).to.throw('Collection class must extend BaseCollection'); + }); + + it('throws an error if schema does not have a service name', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, serviceName: '' })).to.throw('Schema must have a service name'); + }); + + it('throws an error if schema does not have a positive integer', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, schemaVersion: 0 })).to.throw('Schema version must be a positive integer'); + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, schemaVersion: 'test' })).to.throw('Schema version must be a positive integer'); + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, schemaVersion: undefined })).to.throw('Schema version must be a positive integer'); + }); + + it('throws an error if schema does not have attributes', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, attributes: {} })).to.throw('Schema must have attributes'); + }); + + it('throws an error if schema does not have indexes', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, indexes: {} })).to.throw('Schema must have indexes'); + }); + + it('throws an error if schema does not have references', () => { + expect(() => new Schema(MockModel, MockCollection, { ...rawSchema, references: 'test' })).to.throw('References must be an array'); + }); + + it('references default to an empty array', () => { + const schema = new Schema(MockModel, MockCollection, { ...rawSchema, references: undefined }); + + expect(schema.references).to.deep.equal([]); + }); + }); + + describe('accessors', () => { + it('getAttribute', () => { + expect(instance.getAttribute('id')).to.deep.equal({ type: 'string' }); + }); + + it('getAttributes', () => { + expect(instance.getAttributes()).to.deep.equal({ id: { type: 'string' } }); + }); + + it('getCollectionName', () => { + expect(instance.getCollectionName()).to.equal('MockEntityCollection'); + }); + + it('getEntityName', () => { + expect(instance.getEntityName()).to.equal('mockEntityModel'); + }); + + it('getIdName', () => { + expect(instance.getIdName()).to.equal('mockEntityModelId'); + }); + + it('getIndexAccessors', () => { + expect(instance.getIndexAccessors()).to.deep.equal([{ + indexName: 'byOrganizationId', + keySets: [['organizationId']], + }]); + }); + + it('getIndexByName', () => { + expect(instance.getIndexByName('primary')).to.deep.equal({ pk: { composite: ['id'] } }); + }); + + it('getIndexes', () => { + expect(instance.getIndexes()).to.deep.equal(rawSchema.indexes); + }); + + it('getIndexes with exclusion', () => { + expect(instance.getIndexes(['primary'])).to.deep.equal({ + byOrganizationId: { sk: { facets: ['organizationId'] } }, + }); + }); + + it('getIndexKeys', () => { + expect(instance.getIndexKeys('byOrganizationId')).to.deep.equal(['organizationId']); + }); + + it('getIndexKeys with non-existent index', () => { + expect(instance.getIndexKeys('non-existent')).to.deep.equal([]); + }); + + it('getModelClass', () => { + expect(instance.getModelClass()).to.equal(MockModel); + }); + + it('getModelName', () => { + expect(instance.getModelName()).to.equal('MockEntityModel'); + }); + + it('getReciprocalReference', () => { + const reciprocalReference = new Reference('belongs_to', 'MockEntityModel'); + const registry = { + getCollection: () => ({ + schema: { getReferenceByTypeAndTarget: () => reciprocalReference }, + }), + }; + + expect(instance.getReciprocalReference(registry, new Reference('has_many', 'Organization'))) + .to.deep.equal(reciprocalReference); + expect(instance.getReciprocalReference(registry, new Reference('belongs_to', 'Organization'))) + .to.be.null; + }); + + it('getReferences', () => { + expect(instance.getReferences()).to.deep.equal([{ + options: {}, + target: 'Organization', + type: 'belongs_to', + }]); + }); + + it('getReferencesByType', () => { + expect(instance.getReferencesByType('belongs_to')).to.deep.equal([{ + options: {}, + target: 'Organization', + type: 'belongs_to', + }]); + }); + + it('getServiceName', () => { + expect(instance.getServiceName()).to.equal('service'); + }); + + it('getVersion', () => { + expect(instance.getVersion()).to.equal(1); + }); + }); + + describe('toElectroDBSchema', () => { + it('returns an ElectroDB-compatible schema', () => { + expect(instance.toElectroDBSchema()).to.deep.equal({ + model: { + entity: 'MockEntityModel', + version: '1', + service: 'service', + }, + attributes: { id: { type: 'string' } }, + indexes: rawSchema.indexes, + }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.collection.test.js new file mode 100755 index 00000000..433fa74d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.collection.test.js @@ -0,0 +1,107 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import Configuration from '../../../../../src/v2/models/configuration/configuration.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ConfigurationCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + configurationId: '2e6d24e8-3a1f-4c2c-9f80-696a177ff699', + queues: { + someQueue: {}, + }, + jobs: [], + version: 1, + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Configuration, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ConfigurationCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); + + describe('create', () => { + it('creates a new configuration as first version', async () => { + instance.findLatest = stub().resolves(null); + + const result = await instance.create(mockRecord); + + expect(result).to.be.an('object'); + expect(result.getId()).to.equal(mockRecord.configurationId); + }); + + it('creates a new configuration as a new version', async () => { + const latestConfiguration = { + getId: () => 's12345', + getVersion: () => 1, + }; + + instance.findLatest = stub().resolves(latestConfiguration); + mockRecord.version = 2; + + const result = await instance.create(mockRecord); + + expect(result).to.be.an('object'); + expect(result.getId()).to.equal(mockRecord.configurationId); + expect(result.getVersion()).to.equal(2); + }); + }); + + describe('findLatest', () => { + it('returns the latest configuration', async () => { + const mockResult = { configurationId: 's12345' }; + + instance.findByAll = stub().resolves(mockResult); + + const result = await instance.findLatest(); + + expect(result).to.deep.equal(mockResult); + expect(instance.findByAll).to.have.been.calledWithExactly({}, { order: 'desc' }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.model.test.js new file mode 100755 index 00000000..eaaf530c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/configuration/configuration.model.test.js @@ -0,0 +1,211 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import Configuration from '../../../../../src/v2/models/configuration/configuration.model.js'; +import configurationFixtures from '../../../../fixtures/configurations.fixture.js'; +import { createElectroMocks } from '../../util.js'; +import { sanitizeIdAndAuditFields } from '../../../../../src/v2/util/util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const sampleConfiguration = configurationFixtures[0]; +const site = { + getId: () => 'c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe', + getOrganizationId: () => '757ceb98-05c8-4e07-bb23-bc722115b2b0', +}; + +const org = { + getId: () => site.getOrganizationId(), +}; + +describe('ConfigurationModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { ...sampleConfiguration }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Configuration, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Configuration instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('configurationId', () => { + it('gets configurationId', () => { + expect(instance.getId()).to.equal(sampleConfiguration.configurationId); + }); + }); + + describe('attributes', () => { + it('gets version', () => { + expect(instance.getVersion()).to.equal(2); + }); + + it('gets queues', () => { + expect(instance.getQueues()).to.deep.equal(sampleConfiguration.queues); + }); + + it('gets jobs', () => { + expect(instance.getJobs()).to.deep.equal(sampleConfiguration.jobs); + }); + + it('gets handlers', () => { + expect(instance.getHandlers()).to.deep.equal(sampleConfiguration.handlers); + }); + + it('gets handler', () => { + expect(instance.getHandler('apex')).to.deep.equal(sampleConfiguration.handlers.apex); + }); + + it('gets slackRoles', () => { + expect(instance.getSlackRoles()).to.deep.equal(sampleConfiguration.slackRoles); + }); + + it('gets slackRoleMembersByRole', () => { + expect(instance.getSlackRoleMembersByRole('scrape')).to.deep.equal(sampleConfiguration.slackRoles.scrape); + delete instance.record.slackRoles; + expect(instance.getSlackRoleMembersByRole('scrape')).to.deep.equal([]); + }); + }); + + describe('handler enabled/disabled', () => { + it('returns false if a handler does not exist', () => { + expect(instance.isHandlerEnabledForSite('non-existent-handler', site)).to.be.false; + expect(instance.isHandlerEnabledForOrg('non-existent-handler', org)).to.be.false; + }); + + it('returns true if a handler is enabled by default', () => { + expect(instance.isHandlerEnabledForSite('404', site)).to.be.true; + expect(instance.isHandlerEnabledForOrg('404', org)).to.be.true; + }); + + it('returns false if a handler is not enabled by default', () => { + expect(instance.isHandlerEnabledForSite('organic-keywords', site)).to.be.false; + expect(instance.isHandlerEnabledForOrg('organic-keywords', org)).to.be.false; + }); + + it('returns true when a handler is enabled for a site', () => { + expect(instance.isHandlerEnabledForSite('lhs-mobile', site)).to.be.true; + }); + + it('returns false when a handler is disabled for a site', () => { + expect(instance.isHandlerEnabledForSite('cwv', site)).to.be.false; + }); + + it('returns true when a handler is enabled for an organization', () => { + expect(instance.isHandlerEnabledForOrg('lhs-mobile', org)).to.be.true; + }); + + it('returns false when a handler is disabled for an organization', () => { + expect(instance.isHandlerEnabledForOrg('cwv', org)).to.be.false; + }); + + it('gets enabled site ids for a handler', () => { + expect(instance.getEnabledSiteIdsForHandler('lhs-mobile')).to.deep.equal(['c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe']); + delete instance.record.handlers; + expect(instance.getEnabledSiteIdsForHandler('lhs-mobile')).to.deep.equal([]); + }); + }); + + describe('manage handlers', () => { + it('adds a new handler', () => { + const handlerData = { + enabledByDefault: true, + }; + + instance.addHandler('new-handler', handlerData); + expect(instance.getHandler('new-handler')).to.deep.equal(handlerData); + }); + + it('updates handler orgs for a handler disabled by default with enabled', () => { + instance.updateHandlerOrgs('lhs-mobile', org.getId(), true); + expect(instance.getHandler('lhs-mobile').enabled.orgs).to.include(org.getId()); + }); + + it('updates handler orgs for a handler disabled by default with disabled', () => { + instance.updateHandlerOrgs('404', org.getId(), false); + expect(instance.getHandler('404').disabled.orgs).to.include(org.getId()); + }); + + it('updates handler orgs for a handler enabled by default', () => { + instance.updateHandlerOrgs('404', org.getId(), true); + expect(instance.getHandler('404').disabled.orgs).to.not.include(org.getId()); + }); + + it('updates handler sites for a handler disabled by default', () => { + instance.updateHandlerSites('lhs-mobile', site.getId(), true); + expect(instance.getHandler('lhs-mobile').enabled.sites).to.include(site.getId()); + }); + + it('updates handler sites for a handler enabled by default', () => { + instance.updateHandlerSites('404', site.getId(), true); + expect(instance.getHandler('404').disabled.sites).to.not.include(site.getId()); + }); + + it('enables a handler for a site', () => { + instance.enableHandlerForSite('organic-keywords', site); + expect(instance.isHandlerEnabledForSite('organic-keywords', site)).to.be.true; + expect(instance.getHandler('organic-keywords').enabled.sites).to.include(site.getId()); + }); + + it('disables a handler for a site', () => { + instance.enableHandlerForSite('organic-keywords', site); + instance.disableHandlerForSite('organic-keywords', site); + expect(instance.getHandler('organic-keywords').disabled.sites).to.not.include(site.getId()); + }); + + it('enables a handler for an organization', () => { + instance.enableHandlerForOrg('404', org); + expect(instance.getHandler('404').disabled.orgs).to.not.include(org.getId()); + }); + + it('disables a handler for an organization', () => { + instance.enableHandlerForOrg('organic-keywords', org); + instance.disableHandlerForOrg('organic-keywords', org); + expect(instance.getHandler('organic-keywords').enabled.orgs).to.not.include(org.getId()); + }); + }); + + describe('save', () => { + it('saves the configuration', async () => { + instance.collection = { + create: stub().resolves(), + }; + + await instance.save(); + + expect(instance.collection.create).to.have.been.calledOnceWithExactly( + sanitizeIdAndAuditFields('Configuration', instance.toJSON()), + ); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.collection.test.js new file mode 100755 index 00000000..6f3c55e0 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.collection.test.js @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import Experiment from '../../../../../src/v2/models/experiment/experiment.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ExperimentCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + experimentId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Experiment, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ExperimentCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.model.test.js new file mode 100755 index 00000000..c8407e3a --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/experiment/experiment.model.test.js @@ -0,0 +1,203 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import Experiment from '../../../../../src/v2/models/experiment/experiment.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ExperimentModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + experimentId: 'e12345', + siteId: 'site67890', + conversionEventName: 'someConversionEventName', + conversionEventValue: '100', + endDate: '2024-01-01T00:00:00.000Z', + expId: 'someExpId', + name: 'someName', + startDate: '2024-01-01T00:00:00.000Z', + status: 'ACTIVE', + type: 'someType', + url: 'someUrl', + updatedBy: 'someUpdatedBy', + variants: [{ someVariant: 'someVariant' }], + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Experiment, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Experiment instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('experimentId', () => { + it('gets experimentId', () => { + expect(instance.getId()).to.equal('e12345'); + }); + }); + + describe('siteId', () => { + it('gets siteId', () => { + expect(instance.getSiteId()).to.equal('site67890'); + }); + + it('sets siteId', () => { + instance.setSiteId('2c1f0868-cc2d-4358-ba26-a7b5965ee403'); + expect(instance.getSiteId()).to.equal('2c1f0868-cc2d-4358-ba26-a7b5965ee403'); + }); + }); + + describe('conversionEventName', () => { + it('gets conversionEventName', () => { + expect(instance.getConversionEventName()).to.equal('someConversionEventName'); + }); + + it('sets conversionEventName', () => { + instance.setConversionEventName('newConversionEventName'); + expect(instance.getConversionEventName()).to.equal('newConversionEventName'); + }); + }); + + describe('conversionEventValue', () => { + it('gets conversionEventValue', () => { + expect(instance.getConversionEventValue()).to.equal('100'); + }); + + it('sets conversionEventValue', () => { + instance.setConversionEventValue('200'); + expect(instance.getConversionEventValue()).to.equal('200'); + }); + }); + + describe('endDate', () => { + it('gets endDate', () => { + expect(instance.getEndDate()).to.equal('2024-01-01T00:00:00.000Z'); + }); + + it('sets endDate', () => { + const newEndDate = '2024-01-02T00:00:00.000Z'; + instance.setEndDate(newEndDate); + expect(instance.getEndDate()).to.equal(newEndDate); + }); + }); + + describe('expId', () => { + it('gets expId', () => { + expect(instance.getExpId()).to.equal('someExpId'); + }); + + it('sets expId', () => { + instance.setExpId('newExpId'); + expect(instance.getExpId()).to.equal('newExpId'); + }); + }); + + describe('name', () => { + it('gets name', () => { + expect(instance.getName()).to.equal('someName'); + }); + + it('sets name', () => { + instance.setName('newName'); + expect(instance.getName()).to.equal('newName'); + }); + }); + + describe('startDate', () => { + it('gets startDate', () => { + expect(instance.getStartDate()).to.equal('2024-01-01T00:00:00.000Z'); + }); + + it('sets startDate', () => { + const newStartDate = '2024-01-02T00:00:00.000Z'; + instance.setStartDate(newStartDate); + expect(instance.getStartDate()).to.equal(newStartDate); + }); + }); + + describe('status', () => { + it('gets status', () => { + expect(instance.getStatus()).to.equal('ACTIVE'); + }); + + it('sets status', () => { + instance.setStatus('INACTIVE'); + expect(instance.getStatus()).to.equal('INACTIVE'); + }); + }); + + describe('type', () => { + it('gets type', () => { + expect(instance.getType()).to.equal('someType'); + }); + + it('sets type', () => { + instance.setType('newType'); + expect(instance.getType()).to.equal('newType'); + }); + }); + + describe('url', () => { + it('gets url', () => { + expect(instance.getUrl()).to.equal('someUrl'); + }); + + it('sets url', () => { + instance.setUrl('newUrl'); + expect(instance.getUrl()).to.equal('newUrl'); + }); + }); + + describe('updatedBy', () => { + it('gets updatedBy', () => { + expect(instance.getUpdatedBy()).to.equal('someUpdatedBy'); + }); + + it('sets updatedBy', () => { + instance.setUpdatedBy('newUpdatedBy'); + expect(instance.getUpdatedBy()).to.equal('newUpdatedBy'); + }); + }); + + describe('variants', () => { + it('gets variants', () => { + expect(instance.getVariants()).to.deep.equal([{ someVariant: 'someVariant' }]); + }); + + it('sets variants', () => { + instance.setVariants([{ newVariant: 'newVariant' }]); + expect(instance.getVariants()).to.deep.equal([{ newVariant: 'newVariant' }]); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.collection.test.js new file mode 100755 index 00000000..64d6d53d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.collection.test.js @@ -0,0 +1,94 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import ImportJob from '../../../../../src/v2/models/import-job/import-job.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ImportJobCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + importJobId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(ImportJob, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ImportJobCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); + + describe('allByDateRange', () => { + it('throws an error if the startDate is not a valid iso date', async () => { + await expect(instance.allByDateRange()).to.be.rejectedWith('Invalid start date: undefined'); + }); + + it('throws an error if the endDate is not a valid iso date', async () => { + const startIsoDate = '2024-12-06T08:35:24.125Z'; + await expect(instance.allByDateRange(startIsoDate)).to.be.rejectedWith('Invalid end date: undefined'); + }); + + it('returns all import jobs by date range', async () => { + const startIsoDate = '2024-12-06T08:35:24.125Z'; + const endIsoDate = '2024-12-07T08:35:24.125Z'; + + const mockResult = [{ importJobId: 's12345' }]; + + instance.all = stub().resolves(mockResult); + + const result = await instance.allByDateRange(startIsoDate, endIsoDate); + + expect(result).to.deep.equal(mockResult); + expect(instance.all).to.have.been.calledWithExactly({}, { + between: + { + attribute: 'startedAt', + start: '2024-12-06T08:35:24.125Z', + end: '2024-12-07T08:35:24.125Z', + }, + }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.model.test.js new file mode 100755 index 00000000..03490960 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/import-job/import-job.model.test.js @@ -0,0 +1,256 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import ImportJob from '../../../../../src/v2/models/import-job/import-job.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ImportJobModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + importJobId: 'sug12345', + baseURL: 'https://example.com', + duration: 0, + endedAt: '2022-01-01T00:00:00.000Z', + failedCount: 0, + hasCustomHeaders: false, + hasCustomImportJs: false, + hashedApiKey: 'someHashedApiKey', + importQueueId: 'iq12345', + initiatedBy: { + apiKeyName: 'someApiKeyName', + imsOrgId: 'someImsOrgId', + imsUserId: 'someImsUserId', + userAgent: 'someUserAgent', + }, + options: { + someOption: 'someValue', + }, + redirectCount: 0, + status: 'RUNNING', + startedAt: '2022-01-01T00:00:00.000Z', + successCount: 0, + urlCount: 0, + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(ImportJob, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the ImportJob instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('importJobId', () => { + it('gets importJobId', () => { + expect(instance.getId()).to.equal('sug12345'); + }); + }); + + describe('baseURL', () => { + it('gets baseURL', () => { + expect(instance.getBaseURL()).to.equal('https://example.com'); + }); + + it('sets baseURL', () => { + const newBaseURL = 'https://newexample.com'; + instance.setBaseURL(newBaseURL); + expect(instance.getBaseURL()).to.equal(newBaseURL); + }); + }); + + describe('duration', () => { + it('gets duration', () => { + expect(instance.getDuration()).to.equal(0); + }); + + it('sets duration', () => { + const newDuration = 100; + instance.setDuration(newDuration); + expect(instance.getDuration()).to.equal(newDuration); + }); + }); + + describe('endedAt', () => { + it('gets endedAt', () => { + expect(instance.getEndedAt()).to.equal('2022-01-01T00:00:00.000Z'); + }); + + it('sets endedAt', () => { + const newEndedAt = '2023-01-01T00:00:00.000Z'; + instance.setEndedAt(newEndedAt); + expect(instance.getEndedAt()).to.equal(newEndedAt); + }); + }); + + describe('failedCount', () => { + it('gets failedCount', () => { + expect(instance.getFailedCount()).to.equal(0); + }); + + it('sets failedCount', () => { + const newFailedCount = 1; + instance.setFailedCount(newFailedCount); + expect(instance.getFailedCount()).to.equal(newFailedCount); + }); + }); + + describe('hasCustomHeaders', () => { + it('gets hasCustomHeaders', () => { + expect(instance.getHasCustomHeaders()).to.equal(false); + }); + + it('sets hasCustomHeaders', () => { + instance.setHasCustomHeaders(true); + expect(instance.getHasCustomHeaders()).to.equal(true); + }); + }); + + describe('hasCustomImportJs', () => { + it('gets hasCustomImportJs', () => { + expect(instance.getHasCustomImportJs()).to.equal(false); + }); + + it('sets hasCustomImportJson', () => { + instance.setHasCustomImportJs(true); + expect(instance.getHasCustomImportJs()).to.equal(true); + }); + }); + + describe('hashedApiKey', () => { + it('gets hashedApiKey', () => { + expect(instance.getHashedApiKey()).to.equal('someHashedApiKey'); + }); + + it('sets hashedApiKey', () => { + const newHashedApiKey = 'someNewHashedApiKey'; + instance.setHashedApiKey(newHashedApiKey); + expect(instance.getHashedApiKey()).to.equal(newHashedApiKey); + }); + }); + + describe('importQueueId', () => { + it('gets importQueueId', () => { + expect(instance.getImportQueueId()).to.equal('iq12345'); + }); + + it('sets importQueueId', () => { + const newImportQueueId = 'iq67890'; + instance.setImportQueueId(newImportQueueId); + expect(instance.getImportQueueId()).to.equal(newImportQueueId); + }); + }); + + describe('initiatedBy', () => { + it('gets initiatedBy', () => { + expect(instance.getInitiatedBy()).to.deep.equal(mockRecord.initiatedBy); + }); + + it('sets initiatedBy', () => { + const newInitiatedBy = { + apiKeyName: 'newApiKeyName', + imsOrgId: 'newImsOrgId', + imsUserId: 'newImsUserId', + userAgent: 'newUserAgent', + }; + instance.setInitiatedBy(newInitiatedBy); + expect(instance.getInitiatedBy()).to.deep.equal(newInitiatedBy); + }); + }); + + describe('options', () => { + it('gets options', () => { + expect(instance.getOptions()).to.deep.equal({ someOption: 'someValue' }); + }); + + it('sets options', () => { + const newOptions = { newOption: 'newValue' }; + instance.setOptions(newOptions); + expect(instance.getOptions()).to.deep.equal(newOptions); + }); + }); + + describe('redirectCount', () => { + it('gets redirectCount', () => { + expect(instance.getRedirectCount()).to.equal(0); + }); + + it('sets redirectCount', () => { + const newRedirectCount = 1; + instance.setRedirectCount(newRedirectCount); + expect(instance.getRedirectCount()).to.equal(newRedirectCount); + }); + }); + + describe('status', () => { + it('gets status', () => { + expect(instance.getStatus()).to.equal('RUNNING'); + }); + + it('sets status', () => { + const newStatus = 'COMPLETE'; + instance.setStatus(newStatus); + expect(instance.getStatus()).to.equal(newStatus); + }); + }); + + describe('startedAt', () => { + it('gets startedAt', () => { + expect(instance.getStartedAt()).to.equal('2022-01-01T00:00:00.000Z'); + }); + }); + + describe('successCount', () => { + it('gets successCount', () => { + expect(instance.getSuccessCount()).to.equal(0); + }); + + it('sets successCount', () => { + const newSuccessCount = 1; + instance.setSuccessCount(newSuccessCount); + expect(instance.getSuccessCount()).to.equal(newSuccessCount); + }); + }); + + describe('urlCount', () => { + it('gets urlCount', () => { + expect(instance.getUrlCount()).to.equal(0); + }); + + it('sets urlCount', () => { + const newUrlCount = 1; + instance.setUrlCount(newUrlCount); + expect(instance.getUrlCount()).to.equal(newUrlCount); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.collection.test.js new file mode 100755 index 00000000..9291acb6 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.collection.test.js @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import ImportUrl from '../../../../../src/v2/models/import-url/import-url.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ImportUrlCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + importUrlId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(ImportUrl, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the ImportUrlCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.model.test.js new file mode 100755 index 00000000..05a0202b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/import-url/import-url.model.test.js @@ -0,0 +1,141 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import ImportUrl from '../../../../../src/v2/models/import-url/import-url.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('ImportUrlModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + importUrlId: 'sug12345', + importJobId: 'ij12345', + expiresAt: '2022-01-01T00:00:00.000Z', + file: 'someFile', + path: 'somePath', + reason: 'someReason', + status: 'PENDING', + url: 'https://example.com', + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(ImportUrl, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the ImportUrl instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('importUrlId', () => { + it('gets importUrlId', () => { + expect(instance.getId()).to.equal('sug12345'); + }); + }); + + describe('importJobId', () => { + it('gets importJobId', () => { + expect(instance.getImportJobId()).to.equal('ij12345'); + }); + + it('sets importJobId', () => { + instance.setImportJobId('699120e9-7adb-4c97-b1c2-403b6ea9e057'); + expect(instance.getImportJobId()).to.equal('699120e9-7adb-4c97-b1c2-403b6ea9e057'); + }); + }); + + describe('expiresAt', () => { + it('gets expiresAt', () => { + expect(instance.getExpiresAt()).to.equal('2022-01-01T00:00:00.000Z'); + }); + + it('sets expiresAt', () => { + instance.setExpiresAt('2024-01-01T00:00:00.000Z'); + expect(instance.getExpiresAt()).to.equal('2024-01-01T00:00:00.000Z'); + }); + }); + + describe('file', () => { + it('gets file', () => { + expect(instance.getFile()).to.equal('someFile'); + }); + + it('sets file', () => { + instance.setFile('newFile'); + expect(instance.getFile()).to.equal('newFile'); + }); + }); + + describe('path', () => { + it('gets path', () => { + expect(instance.getPath()).to.equal('somePath'); + }); + + it('sets path', () => { + instance.setPath('newPath'); + expect(instance.getPath()).to.equal('newPath'); + }); + }); + + describe('reason', () => { + it('gets reason', () => { + expect(instance.getReason()).to.equal('someReason'); + }); + + it('sets reason', () => { + instance.setReason('newReason'); + expect(instance.getReason()).to.equal('newReason'); + }); + }); + + describe('status', () => { + it('gets status', () => { + expect(instance.getStatus()).to.equal('PENDING'); + }); + + it('sets status', () => { + instance.setStatus('COMPLETE'); + expect(instance.getStatus()).to.equal('COMPLETE'); + }); + }); + + describe('url', () => { + it('gets url', () => { + expect(instance.getUrl()).to.equal('https://example.com'); + }); + + it('sets url', () => { + instance.setUrl('https://example.org'); + expect(instance.getUrl()).to.equal('https://example.org'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.collection.test.js new file mode 100755 index 00000000..1f2672a3 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.collection.test.js @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import KeyEvent from '../../../../../src/v2/models/key-event/key-event.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('KeyEventCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + keyEventId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(KeyEvent, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the KeyEventCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.model.test.js new file mode 100755 index 00000000..193d4901 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/key-event/key-event.model.test.js @@ -0,0 +1,106 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import KeyEvent from '../../../../../src/v2/models/key-event/key-event.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('KeyEventModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + keyEventId: 'k12345', + siteId: 's12345', + name: 'someName', + type: 'CONTENT', + time: '2022-01-01T00:00:00.000Z', + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(KeyEvent, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the KeyEvent instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('keyEventId', () => { + it('gets keyEventId', () => { + expect(instance.getId()).to.equal('k12345'); + }); + }); + + describe('siteId', () => { + it('gets siteId', () => { + expect(instance.getSiteId()).to.equal('s12345'); + }); + + it('sets siteId', () => { + instance.setSiteId('51f2eab9-2cd8-47a0-acd0-a2b00d916792'); + expect(instance.getSiteId()).to.equal('51f2eab9-2cd8-47a0-acd0-a2b00d916792'); + }); + }); + + describe('name', () => { + it('gets name', () => { + expect(instance.getName()).to.equal('someName'); + }); + + it('sets name', () => { + instance.setName('newName'); + expect(instance.getName()).to.equal('newName'); + }); + }); + + describe('type', () => { + it('gets type', () => { + expect(instance.getType()).to.equal('CONTENT'); + }); + + it('sets type', () => { + instance.setType('STATUS CHANGE'); + expect(instance.getType()).to.equal('STATUS CHANGE'); + }); + }); + + describe('time', () => { + it('gets time', () => { + expect(instance.getTime()).to.equal('2022-01-01T00:00:00.000Z'); + }); + + it('sets time', () => { + const newTime = '2023-01-01T00:00:00.000Z'; + instance.setTime(newTime); + expect(instance.getTime()).to.equal(newTime); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/model.factory.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/model.factory.test.js deleted file mode 100755 index f3a1c345..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/model.factory.test.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use as chaiUse } from 'chai'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import ModelFactory from '../../../../src/v2/models/model.factory.js'; - -chaiUse(chaiAsPromised); - -/** - * Mock services and logger for unit testing - */ -const mockElectroService = { - entities: {}, -}; - -const mockLogger = { - error: spy(), -}; - -// ModelFactory Unit Tests -describe('ModelFactory', () => { - let modelFactoryInstance; - let mockCollectionInstance; - - beforeEach(() => { - mockCollectionInstance = { - findById: stub(), - create: stub(), - }; - modelFactoryInstance = new ModelFactory(mockElectroService, mockLogger); - }); - - describe('constructor', () => { - it('initializes the ModelFactory instance correctly', () => { - expect(modelFactoryInstance).to.be.an('object'); - expect(modelFactoryInstance.service).to.equal(mockElectroService); - expect(modelFactoryInstance.logger).to.equal(mockLogger); - expect(modelFactoryInstance.models).to.be.a('map'); - }); - }); - - describe('getCollection', () => { - it('returns an existing collection if already initialized', () => { - modelFactoryInstance.models.set('TestCollection', mockCollectionInstance); - const result = modelFactoryInstance.getCollection('TestCollection'); - expect(result).to.equal(mockCollectionInstance); - }); - - it('throws an error if the collection name is not valid', () => { - expect(() => modelFactoryInstance.getCollection('InvalidCollection')) - .to.throw('Collection InvalidCollection not found'); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.collection.test.js deleted file mode 100644 index d7c5fa02..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.collection.test.js +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use as chaiUse } from 'chai'; -import { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import Opportunity from '../../../../src/v2/models/opportunity.model.js'; -import OpportunityCollection from '../../../../src/v2/models/opportunity.collection.js'; -import OpportunitySchema from '../../../../src/v2/schema/opportunity.schema.js'; - -chaiUse(chaiAsPromised); - -const opportunityEntity = new Entity(OpportunitySchema); - -const mockElectroService = { - entities: { - opportunity: { - model: { - name: 'opportunity', - schema: opportunityEntity.model.schema, - original: { - references: {}, - }, - }, - query: { - bySiteId: stub(), - bySiteIdAndStatus: stub(), - }, - put: stub(), - }, - }, -}; - -// OpportunityCollection Unit Tests -describe('OpportunityCollection', () => { - let opportunityCollectionInstance; - let mockLogger; - let mockModelFactory; - - const mockRecord = { - opportunityId: 'op12345', - siteId: 'site67890', - data: { - foo: 'bar', - bing: 'batz', - }, - }; - const mockOpportunityModel = new Opportunity( - mockElectroService, - mockModelFactory, - mockRecord, - mockLogger, - ); - - beforeEach(() => { - mockLogger = { - error: spy(), - warn: spy(), - }; - - mockModelFactory = { - getCollection: stub(), - }; - - opportunityCollectionInstance = new OpportunityCollection( - mockElectroService, - mockModelFactory, - mockLogger, - ); - }); - - describe('constructor', () => { - it('initializes the OpportunityCollection instance correctly', () => { - expect(opportunityCollectionInstance).to.be.an('object'); - expect(opportunityCollectionInstance.electroService).to.equal(mockElectroService); - expect(opportunityCollectionInstance.modelFactory).to.equal(mockModelFactory); - expect(opportunityCollectionInstance.log).to.equal(mockLogger); - }); - }); - - describe('allBySiteId', () => { - it('returns an array of Opportunity instances when opportunities exist', async () => { - const mockFindResults = { data: [mockRecord] }; - mockElectroService.entities.opportunity.query.bySiteId.returns( - { go: () => Promise.resolve(mockFindResults) }, - ); - - const results = await opportunityCollectionInstance.allBySiteId('site67890'); - expect(results).to.be.an('array').that.has.length(1); - expect(results[0]).to.be.instanceOf(Opportunity); - expect(results[0].record).to.deep.include(mockOpportunityModel.record); - }); - - it('returns an empty array if no opportunities exist for the given site ID', async () => { - mockElectroService.entities.opportunity.query.bySiteId.returns( - { go: () => Promise.resolve([]) }, - ); - - const results = await opportunityCollectionInstance.allBySiteId('site67890'); - expect(results).to.be.an('array').that.is.empty; - }); - - it('throws an error if siteId is not provided', async () => { - await expect(opportunityCollectionInstance.allBySiteId('')) - .to.be.rejectedWith('SiteId is required'); - }); - }); - - describe('allBySiteIdAndStatus', () => { - it('returns an array of Opportunity instances when opportunities exist', async () => { - const mockFindResults = { data: [mockRecord] }; - mockElectroService.entities.opportunity.query.bySiteIdAndStatus.returns( - { go: () => Promise.resolve(mockFindResults) }, - ); - - const results = await opportunityCollectionInstance.allBySiteIdAndStatus('site67890', 'IN_PROGRESS'); - expect(results).to.be.an('array').that.has.length(1); - expect(results[0]).to.be.instanceOf(Opportunity); - expect(results[0].record).to.deep.include(mockOpportunityModel.record); - }); - - it('returns an empty array if no opportunities exist for the given site ID and status', async () => { - mockElectroService.entities.opportunity.query.bySiteIdAndStatus.returns( - { go: () => Promise.resolve([]) }, - ); - - const results = await opportunityCollectionInstance.allBySiteIdAndStatus('site67890', 'IN_PROGRESS'); - expect(results).to.be.an('array').that.is.empty; - }); - - it('throws an error if siteId is not provided', async () => { - await expect(opportunityCollectionInstance.allBySiteIdAndStatus('', 'IN_PROGRESS')) - .to.be.rejectedWith('SiteId is required'); - }); - - it('throws an error if status is not provided', async () => { - await expect(opportunityCollectionInstance.allBySiteIdAndStatus('site67890', '')) - .to.be.rejectedWith('Status is required'); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.model.test.js deleted file mode 100644 index 7870dde5..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity.model.test.js +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use as chaiUse } from 'chai'; -import { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import Opportunity from '../../../../src/v2/models/opportunity.model.js'; -import OpportunitySchema from '../../../../src/v2/schema/opportunity.schema.js'; - -chaiUse(chaiAsPromised); - -const { attributes } = new Entity(OpportunitySchema).model.schema; - -const mockElectroService = { - entities: { - opportunity: { - model: { - name: 'opportunity', - schema: { attributes }, - original: { - references: {}, - }, - indexes: { - primary: { - pk: { - field: 'pk', - composite: ['opportunityId'], - }, - }, - }, - }, - patch: stub().returns({ - set: stub(), - }), - }, - }, -}; - -describe('Opportunity', () => { - let opportunityInstance; - let mockModelFactory; - let mockLogger; - - const mockRecord = { - opportunityId: 'op12345', - siteId: 'site67890', - auditId: 'audit001', - title: 'Test Opportunity', - description: 'This is a test opportunity.', - runbook: 'http://runbook.url', - guidance: 'Follow these steps.', - type: 'SEO', - status: 'NEW', - origin: 'ESS_OPS', - tags: ['tag1', 'tag2'], - data: { - additionalInfo: 'info', - }, - }; - - beforeEach(() => { - mockModelFactory = { - getCollection: stub(), - }; - - mockLogger = { - error: spy(), - }; - - opportunityInstance = new Opportunity( - mockElectroService, - mockModelFactory, - mockRecord, - mockLogger, - ); - }); - - describe('constructor', () => { - it('initializes the Opportunity instance correctly', () => { - expect(opportunityInstance).to.be.an('object'); - expect(opportunityInstance.record).to.deep.equal(mockRecord); - }); - }); - - describe('addSuggestions', () => { - it('adds related suggestions to the opportunity', async () => { - const mockSuggestionCollection = { - createMany: stub().returns(Promise.resolve({ id: 'suggestion-1' })), - }; - mockModelFactory.getCollection.withArgs('SuggestionCollection').returns(mockSuggestionCollection); - - const suggestion = await opportunityInstance.addSuggestions([{ text: 'Suggestion text' }]); - expect(suggestion).to.deep.equal({ id: 'suggestion-1' }); - expect(mockModelFactory.getCollection.calledOnceWith('SuggestionCollection')).to.be.true; - expect(mockSuggestionCollection.createMany.calledOnceWith([{ text: 'Suggestion text', opportunityId: 'op12345' }])).to.be.true; - }); - }); - - describe('getSiteId and setSiteId', () => { - it('returns the site ID of the opportunity', () => { - expect(opportunityInstance.getSiteId()).to.equal('site67890'); - }); - - it('sets the site ID of the opportunity', () => { - opportunityInstance.setSiteId('ef39921f-9a02-41db-b491-02c98987d956'); - expect(opportunityInstance.record.siteId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); - }); - }); - - describe('getAuditId and setAuditId', () => { - it('returns the audit ID of the opportunity', () => { - expect(opportunityInstance.getAuditId()).to.equal('audit001'); - }); - - it('sets the audit ID of the opportunity', () => { - opportunityInstance.setAuditId('ef39921f-9a02-41db-b491-02c98987d956'); - expect(opportunityInstance.record.auditId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); - }); - }); - - describe('getRunbook and setRunbook', () => { - it('returns the runbook reference', () => { - expect(opportunityInstance.getRunbook()).to.equal('http://runbook.url'); - }); - - it('sets the runbook reference', () => { - opportunityInstance.setRunbook('http://new.runbook.url'); - expect(opportunityInstance.record.runbook).to.equal('http://new.runbook.url'); - }); - }); - - describe('getGuidance and setGuidance', () => { - it('returns the guidance information', () => { - expect(opportunityInstance.getGuidance()).to.equal('Follow these steps.'); - }); - - it('sets the guidance information', () => { - opportunityInstance.setGuidance({ text: 'New guidance text' }); - expect(opportunityInstance.record.guidance).to.eql({ text: 'New guidance text' }); - }); - }); - - describe('getTitle and setTitle', () => { - it('returns the title of the opportunity', () => { - expect(opportunityInstance.getTitle()).to.equal('Test Opportunity'); - }); - - it('sets the title of the opportunity', () => { - opportunityInstance.setTitle('New Opportunity Title'); - expect(opportunityInstance.record.title).to.equal('New Opportunity Title'); - }); - }); - - describe('getDescription and setDescription', () => { - it('returns the description of the opportunity', () => { - expect(opportunityInstance.getDescription()).to.equal('This is a test opportunity.'); - }); - - it('sets the description of the opportunity', () => { - opportunityInstance.setDescription('Updated description.'); - expect(opportunityInstance.record.description).to.equal('Updated description.'); - }); - }); - - describe('getType', () => { - it('returns the type of the opportunity', () => { - expect(opportunityInstance.getType()).to.equal('SEO'); - }); - }); - - describe('getStatus and setStatus', () => { - it('returns the status of the opportunity', () => { - expect(opportunityInstance.getStatus()).to.equal('NEW'); - }); - - it('sets the status of the opportunity', () => { - opportunityInstance.setStatus('IN_PROGRESS'); - expect(opportunityInstance.record.status).to.equal('IN_PROGRESS'); - }); - }); - - describe('getOrigin and setOrigin', () => { - it('returns the origin of the opportunity', () => { - expect(opportunityInstance.getOrigin()).to.equal('ESS_OPS'); - }); - - it('sets the origin of the opportunity', () => { - opportunityInstance.setOrigin('AI'); - expect(opportunityInstance.record.origin).to.equal('AI'); - }); - }); - - describe('getTags and setTags', () => { - it('returns the tags of the opportunity', () => { - expect(opportunityInstance.getTags()).to.deep.equal(['tag1', 'tag2']); - }); - - it('sets the tags of the opportunity', () => { - opportunityInstance.setTags(['newTag1', 'newTag2']); - expect(opportunityInstance.record.tags).to.deep.equal(['newTag1', 'newTag2']); - }); - }); - - describe('getData and setData', () => { - it('returns additional data for the opportunity', () => { - expect(opportunityInstance.getData()).to.deep.equal({ additionalInfo: 'info' }); - }); - - it('sets additional data for the opportunity', () => { - opportunityInstance.setData({ newInfo: 'updatedInfo' }); - expect(opportunityInstance.record.data).to.deep.equal({ newInfo: 'updatedInfo' }); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.collection.test.js new file mode 100755 index 00000000..093e0145 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.collection.test.js @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import Opportunity from '../../../../../src/v2/models/opportunity/opportunity.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('OpportunityCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + opportunityId: 'op12345', + siteId: 'site67890', + auditId: 'audit001', + title: 'Test Opportunity', + description: 'This is a test opportunity.', + runbook: 'http://runbook.url', + guidance: 'Follow these steps.', + type: 'SEO', + status: 'NEW', + origin: 'ESS_OPS', + tags: ['tag1', 'tag2'], + data: { + additionalInfo: 'info', + }, + updatedAt: '2022-01-01T00:00:00.000Z', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Opportunity, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the OpportunityCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.model.test.js new file mode 100755 index 00000000..cbce5081 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/opportunity/opportunity.model.test.js @@ -0,0 +1,196 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import Opportunity from '../../../../../src/v2/models/opportunity/opportunity.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('OpportunityModel', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockRecord; + + beforeEach(() => { + mockRecord = { + opportunityId: 'op12345', + siteId: 'site67890', + auditId: 'audit001', + title: 'Test Opportunity', + description: 'This is a test opportunity.', + runbook: 'http://runbook.url', + guidance: 'Follow these steps.', + type: 'SEO', + status: 'NEW', + origin: 'ESS_OPS', + tags: ['tag1', 'tag2'], + data: { + additionalInfo: 'info', + }, + }; + + ({ + mockElectroService, + mockEntityRegistry, + model: instance, + } = createElectroMocks(Opportunity, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Opportunity instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('addSuggestions', () => { + it('adds related suggestions to the opportunity', async () => { + const mockSuggestionCollection = { + createMany: stub().returns(Promise.resolve({ id: 'suggestion-1' })), + }; + mockEntityRegistry.getCollection.withArgs('SuggestionCollection').returns(mockSuggestionCollection); + + const suggestion = await instance.addSuggestions([{ text: 'Suggestion text' }]); + expect(suggestion).to.deep.equal({ id: 'suggestion-1' }); + expect(mockEntityRegistry.getCollection.calledWith('SuggestionCollection')).to.be.true; + expect(mockSuggestionCollection.createMany.calledOnceWith([{ text: 'Suggestion text', opportunityId: 'op12345' }])).to.be.true; + }); + }); + + describe('getSiteId and setSiteId', () => { + it('returns the site ID of the opportunity', () => { + expect(instance.getSiteId()).to.equal('site67890'); + }); + + it('sets the site ID of the opportunity', () => { + instance.setSiteId('ef39921f-9a02-41db-b491-02c98987d956'); + expect(instance.record.siteId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); + }); + }); + + describe('getAuditId and setAuditId', () => { + it('returns the audit ID of the opportunity', () => { + expect(instance.getAuditId()).to.equal('audit001'); + }); + + it('sets the audit ID of the opportunity', () => { + instance.setAuditId('ef39921f-9a02-41db-b491-02c98987d956'); + expect(instance.record.auditId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); + }); + }); + + describe('getRunbook and setRunbook', () => { + it('returns the runbook reference', () => { + expect(instance.getRunbook()).to.equal('http://runbook.url'); + }); + + it('sets the runbook reference', () => { + instance.setRunbook('http://new.runbook.url'); + expect(instance.record.runbook).to.equal('http://new.runbook.url'); + }); + }); + + describe('getGuidance and setGuidance', () => { + it('returns the guidance information', () => { + expect(instance.getGuidance()).to.equal('Follow these steps.'); + }); + + it('sets the guidance information', () => { + instance.setGuidance({ text: 'New guidance text' }); + expect(instance.record.guidance).to.eql({ text: 'New guidance text' }); + }); + }); + + describe('getTitle and setTitle', () => { + it('returns the title of the opportunity', () => { + expect(instance.getTitle()).to.equal('Test Opportunity'); + }); + + it('sets the title of the opportunity', () => { + instance.setTitle('New Opportunity Title'); + expect(instance.record.title).to.equal('New Opportunity Title'); + }); + }); + + describe('getDescription and setDescription', () => { + it('returns the description of the opportunity', () => { + expect(instance.getDescription()).to.equal('This is a test opportunity.'); + }); + + it('sets the description of the opportunity', () => { + instance.setDescription('Updated description.'); + expect(instance.record.description).to.equal('Updated description.'); + }); + }); + + describe('getType', () => { + it('returns the type of the opportunity', () => { + expect(instance.getType()).to.equal('SEO'); + }); + }); + + describe('getStatus and setStatus', () => { + it('returns the status of the opportunity', () => { + expect(instance.getStatus()).to.equal('NEW'); + }); + + it('sets the status of the opportunity', () => { + instance.setStatus('IN_PROGRESS'); + expect(instance.record.status).to.equal('IN_PROGRESS'); + }); + }); + + describe('getOrigin and setOrigin', () => { + it('returns the origin of the opportunity', () => { + expect(instance.getOrigin()).to.equal('ESS_OPS'); + }); + + it('sets the origin of the opportunity', () => { + instance.setOrigin('AI'); + expect(instance.record.origin).to.equal('AI'); + }); + }); + + describe('getTags and setTags', () => { + it('returns the tags of the opportunity', () => { + expect(instance.getTags()).to.deep.equal(['tag1', 'tag2']); + }); + + it('sets the tags of the opportunity', () => { + instance.setTags(['newTag1', 'newTag2']); + expect(instance.record.tags).to.deep.equal(['newTag1', 'newTag2']); + }); + }); + + describe('getData and setData', () => { + it('returns additional data for the opportunity', () => { + expect(instance.getData()).to.deep.equal({ additionalInfo: 'info' }); + }); + + it('sets additional data for the opportunity', () => { + instance.setData({ newInfo: 'updatedInfo' }); + expect(instance.record.data).to.deep.equal({ newInfo: 'updatedInfo' }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.collection.test.js new file mode 100755 index 00000000..df823d72 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.collection.test.js @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import Organization from '../../../../../src/v2/models/organization/organization.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('OrganizationCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + organizationId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Organization, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the OrganizationCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.model.test.js new file mode 100755 index 00000000..261a1f70 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/organization/organization.model.test.js @@ -0,0 +1,100 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import Organization from '../../../../../src/v2/models/organization/organization.model.js'; +import organizationFixtures from '../../../../fixtures/organizations.fixture.js'; +import { Config } from '../../../../../src/models/site/config.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const sampleOrganization = organizationFixtures[0]; + +describe('OrganizationModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = sampleOrganization; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Organization, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Organization instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('organizationId', () => { + it('gets organizationId', () => { + expect(instance.getId()).to.equal('4854e75e-894b-4a74-92bf-d674abad1423'); + }); + }); + + describe('config', () => { + it('gets config', () => { + const config = Config.toDynamoItem(instance.getConfig()); + delete config.imports; + expect(config).to.deep.equal(sampleOrganization.config); + }); + }); + + describe('name', () => { + it('gets name', () => { + expect(instance.getName()).to.equal('0-1234Name'); + }); + + it('sets name', () => { + instance.setName('Some Name'); + expect(instance.record.name).to.equal('Some Name'); + }); + }); + + describe('imsOrgId', () => { + it('gets imsOrgId', () => { + expect(instance.getImsOrgId()).to.equal('0-1234@AdobeOrg'); + }); + + it('sets imsOrgId', () => { + instance.setImsOrgId('newImsOrgId'); + expect(instance.getImsOrgId()).to.equal('newImsOrgId'); + }); + }); + + describe('fulfillableItems', () => { + it('gets fulfillableItems', () => { + expect(instance.getFulfillableItems()).to.deep.equal(undefined); + }); + + it('sets fulfillableItems', () => { + instance.setFulfillableItems(['item3', 'item4']); + expect(instance.getFulfillableItems()).to.deep.equal(['item3', 'item4']); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/site-candidate/site-candidate.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/site-candidate/site-candidate.collection.test.js new file mode 100755 index 00000000..ed74a345 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/site-candidate/site-candidate.collection.test.js @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import SiteCandidate from '../../../../../src/v2/models/site-candidate/site-candidate.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SiteCandidateCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + siteCandidateId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(SiteCandidate, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the SiteCandidateCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/site-top-page/site-top-page.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/site-top-page/site-top-page.collection.test.js new file mode 100755 index 00000000..bd4defe8 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/site-top-page/site-top-page.collection.test.js @@ -0,0 +1,93 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import SiteTopPage from '../../../../../src/v2/models/site-top-page/site-top-page.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SiteTopPageCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + siteTopPageId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(SiteTopPage, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the SiteTopPageCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); + + describe('removeForSiteId', () => { + it('throws an error if siteId is not provided', async () => { + await expect(instance.removeForSiteId()).to.be.rejectedWith('SiteId is required'); + }); + + it('removes all SiteTopPages for a given siteId', async () => { + const siteId = 'site12345'; + + instance.allBySiteId = stub().resolves([model]); + + await instance.removeForSiteId(siteId); + + expect(instance.allBySiteId.calledOnceWith(siteId)).to.be.true; + expect(mockElectroService.entities.siteTopPage.delete.calledOnceWith([{ siteTopPageId: 's12345' }])) + .to.be.true; + }); + + it('remove all SiteTopPages for a given siteId, source and geo', async () => { + const siteId = 'site12345'; + const source = 'ahrefs'; + const geo = 'global'; + + instance.allBySiteIdAndSourceAndGeo = stub().resolves([model]); + + await instance.removeForSiteId(siteId, source, geo); + + expect(instance.allBySiteIdAndSourceAndGeo).to.have.been.calledOnceWith(siteId, source, geo); + expect(mockElectroService.entities.siteTopPage.delete).to.have.been.calledOnceWith([{ siteTopPageId: 's12345' }]); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.collection.test.js new file mode 100755 index 00000000..856649d9 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.collection.test.js @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import Site from '../../../../../src/v2/models/site/site.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SiteCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + siteId: 's12345', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Site, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the SiteCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); + + describe('allSitesToAudit', () => { + it('returns all sites to audit', async () => { + instance.all = stub().resolves([{ getId: () => 's12345' }]); + + const result = await instance.allSitesToAudit(); + + expect(result).to.deep.equal(['s12345']); + expect(instance.all).to.have.been.calledOnceWithExactly({ attributes: ['siteId'] }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.model.test.js new file mode 100755 index 00000000..5786d85d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/site/site.model.test.js @@ -0,0 +1,178 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import Site from '../../../../../src/v2/models/site/site.model.js'; +import siteFixtures from '../../../../fixtures/sites.fixture.js'; +import { Config } from '../../../../../src/models/site/config.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const sampleSite = siteFixtures[0]; + +describe('SiteModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = sampleSite; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Site, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Site instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('siteId', () => { + it('gets siteId', () => { + expect(instance.getId()).to.equal('5d6d4439-6659-46c2-b646-92d110fa5a52'); + }); + }); + + describe('organizationId', () => { + it('gets organizationId', () => { + expect(instance.getOrganizationId()).to.equal('4854e75e-894b-4a74-92bf-d674abad1423'); + }); + + it('sets organizationId', () => { + instance.setOrganizationId('1e9c6f94-f226-41f3-9005-4bb766765ac2'); + expect(instance.record.organizationId).to.equal('1e9c6f94-f226-41f3-9005-4bb766765ac2'); + }); + }); + + describe('baseURL', () => { + it('gets baseURL', () => { + expect(instance.getBaseURL()).to.equal('https://example0.com'); + }); + + it('sets baseURL', () => { + instance.setBaseURL('https://www.example.org'); + expect(instance.getBaseURL()).to.equal('https://www.example.org'); + }); + }); + + describe('config', () => { + it('gets config', () => { + const config = Config.toDynamoItem(instance.getConfig()); + delete config.imports; + expect(config).to.deep.equal(sampleSite.config); + }); + }); + + describe('gitHubURL', () => { + it('gets gitHubURL', () => { + expect(instance.getGitHubURL()).to.equal('https://github.com/org-0/test-repo'); + }); + + it('sets gitHubURL', () => { + instance.setGitHubURL('new-github-url'); + expect(instance.getGitHubURL()).to.equal('new-github-url'); + }); + }); + + describe('deliveryType', () => { + it('gets deliveryType', () => { + expect(instance.getDeliveryType()).to.equal('aem_edge'); + }); + + it('sets deliveryType', () => { + instance.setDeliveryType('aem_cs'); + expect(instance.getDeliveryType()).to.equal('aem_cs'); + }); + }); + + describe('hlxConfig', () => { + it('gets hlxConfig', () => { + expect(instance.getHlxConfig()).to.deep.equal(undefined); + }); + + it('sets hlxConfig', () => { + const newHlxConfig = { bar: 'baz' }; + instance.setHlxConfig(newHlxConfig); + expect(instance.getHlxConfig()).to.deep.equal(newHlxConfig); + }); + }); + + describe('isLive', () => { + it('gets isLive', () => { + expect(instance.getIsLive()).to.equal(true); + }); + + it('sets isLive', () => { + instance.setIsLive(false); + expect(instance.getIsLive()).to.equal(false); + }); + }); + + describe('isLiveToggledAt', () => { + it('gets isLiveToggledAt', () => { + expect(instance.getIsLiveToggledAt()).to.equal('2024-11-29T07:45:55.952Z'); + }); + + it('sets isLiveToggledAt', () => { + instance.setIsLiveToggledAt('2024-01-02T00:00:00.000Z'); + expect(instance.getIsLiveToggledAt()).to.equal('2024-01-02T00:00:00.000Z'); + }); + }); + + describe('getLatestAuditByType', () => { + it('returns the latest audit by type', async () => { + const mockAudit = { + auditType: 'someAuditType', + auditedAt: '2024-01-01T00:00:00.000Z', + }; + + const mockFind = stub().returns(mockAudit); + + instance.entityRegistry = { + getCollection: stub().returns({ findByIndexKeys: mockFind }), + }; + + const latestAudit = await instance.getLatestAuditByType('someAuditType'); + + expect(latestAudit).to.deep.equal(mockAudit); + expect(instance.entityRegistry.getCollection).to.have.been.calledOnceWithExactly('AuditCollection'); + expect(mockFind).to.have.been.calledOnceWithExactly( + { siteId: '5d6d4439-6659-46c2-b646-92d110fa5a52', auditType: 'someAuditType' }, + ); + }); + }); + + describe('toggleLive', () => { + it('toggles the site live status', async () => { + expect(instance.getIsLive()).to.equal(false); + + instance.toggleLive(); + + expect(instance.getIsLive()).to.equal(true); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js deleted file mode 100644 index 96d50670..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use as chaiUse } from 'chai'; -import { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import SuggestionCollection from '../../../../src/v2/models/suggestion.collection.js'; -import Suggestion from '../../../../src/v2/models/suggestion.model.js'; -import SuggestionSchema from '../../../../src/v2/schema/suggestion.schema.js'; - -chaiUse(chaiAsPromised); - -const { attributes } = new Entity(SuggestionSchema).model.schema; - -const mockElectroService = { - entities: { - suggestion: { - model: { - name: 'suggestion', - schema: { attributes }, - original: { - references: {}, - }, - indexes: { - primary: { - pk: { - field: 'pk', - composite: ['suggestionId'], - }, - }, - }, - }, - query: { - byOpportunityId: stub(), - byOpportunityIdAndStatus: stub(), - }, - put: stub().returns({ - go: stub().resolves({}), - }), - patch: stub().returns({ - set: stub(), - }), - }, - }, -}; - -// SuggestionCollection Unit Tests -describe('SuggestionCollection', () => { - let suggestionCollectionInstance; - let mockLogger; - let mockModelFactory; - - const mockRecord = { - suggestionId: 's12345', - opportunityId: 'op67890', - data: { - title: 'Test Suggestion', - description: 'This is a test suggestion.', - }, - }; - const mockSuggestionModel = new Suggestion( - mockElectroService, - mockModelFactory, - mockRecord, - mockLogger, - ); - - beforeEach(() => { - mockLogger = { - error: spy(), - warn: spy(), - }; - - mockModelFactory = { - getCollection: stub(), - }; - - suggestionCollectionInstance = new SuggestionCollection( - mockElectroService, - mockModelFactory, - mockLogger, - ); - }); - - describe('constructor', () => { - it('initializes the SuggestionCollection instance correctly', () => { - expect(suggestionCollectionInstance).to.be.an('object'); - expect(suggestionCollectionInstance.electroService).to.equal(mockElectroService); - expect(suggestionCollectionInstance.modelFactory).to.equal(mockModelFactory); - expect(suggestionCollectionInstance.log).to.equal(mockLogger); - }); - }); - - describe('allByOpportunityId', () => { - it('returns the suggestions by opportunity', async () => { - const mockFindResults = { data: [mockRecord] }; - mockElectroService.entities.suggestion.query.byOpportunityId.returns( - { go: () => Promise.resolve(mockFindResults) }, - ); - - const results = await suggestionCollectionInstance.allByOpportunityId('op67890'); - expect(results).to.be.an('array').that.has.length(1); - expect(results[0]).to.be.instanceOf(Suggestion); - expect(results[0].record).to.deep.include(mockSuggestionModel.record); - }); - - it('returns an empty array if no suggestions exist for the given opportunity ID', async () => { - mockElectroService.entities.suggestion.query.byOpportunityId.returns( - { go: () => Promise.resolve([]) }, - ); - - const results = await suggestionCollectionInstance.allByOpportunityId('op67890'); - expect(results).to.be.an('array').that.is.empty; - }); - - it('throws an error if opportunityId is not provided', async () => { - await expect(suggestionCollectionInstance.allByOpportunityId('')) - .to.be.rejectedWith('OpportunityId is required'); - }); - }); - - describe('allByOpportunityIdAndStatus', () => { - it('returns the suggestions by opportunity and status', async () => { - const mockFindResults = { data: [mockRecord] }; - mockElectroService.entities.suggestion.query.byOpportunityIdAndStatus.returns( - { go: () => Promise.resolve(mockFindResults) }, - ); - - const results = await suggestionCollectionInstance.allByOpportunityIdAndStatus('op67890', 'NEW'); - expect(results).to.be.an('array').that.has.length(1); - expect(results[0]).to.be.instanceOf(Suggestion); - expect(results[0].record).to.deep.include(mockSuggestionModel.record); - }); - - it('returns an empty array if no suggestions exist for the given opportunity ID and status', async () => { - mockElectroService.entities.suggestion.query.byOpportunityIdAndStatus.returns( - { go: () => Promise.resolve([]) }, - ); - - const results = await suggestionCollectionInstance.allByOpportunityIdAndStatus('op67890', 'NEW'); - expect(results).to.be.an('array').that.is.empty; - }); - - it('throws an error if opportunityId is not provided', async () => { - await expect(suggestionCollectionInstance.allByOpportunityIdAndStatus('', 'NEW')) - .to.be.rejectedWith('OpportunityId is required'); - }); - - it('throws an error if status is not provided', async () => { - await expect(suggestionCollectionInstance.allByOpportunityIdAndStatus('op67890', '')) - .to.be.rejectedWith('Status is required'); - }); - }); - - describe('bulkUpdateStatus', () => { - it('updates the status of multiple suggestions', async () => { - const mockSuggestions = [mockSuggestionModel]; - const mockStatus = 'NEW'; - - await suggestionCollectionInstance.bulkUpdateStatus(mockSuggestions, mockStatus); - - expect(mockElectroService.entities.suggestion.put.calledOnce).to.be.true; - expect(mockElectroService.entities.suggestion.put.firstCall.args[0]).to.deep.equal([{ - suggestionId: 's12345', - opportunityId: 'op67890', - data: { - title: 'Test Suggestion', - description: 'This is a test suggestion.', - }, - status: 'NEW', - }]); - }); - - it('throws an error if suggestions is not an array', async () => { - await expect(suggestionCollectionInstance.bulkUpdateStatus({}, 'NEW')) - .to.be.rejectedWith('Suggestions must be an array'); - }); - - it('throws an error if status is not provided', async () => { - await expect(suggestionCollectionInstance.bulkUpdateStatus([mockSuggestionModel], 'foo')) - .to.be.rejectedWith('Invalid status: foo. Must be one of: NEW, APPROVED, SKIPPED, FIXED, ERROR'); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.model.test.js deleted file mode 100644 index a6eb5cf2..00000000 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.model.test.js +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ - -import { expect, use as chaiUse } from 'chai'; -import { Entity } from 'electrodb'; -import { spy, stub } from 'sinon'; -import chaiAsPromised from 'chai-as-promised'; - -import Suggestion from '../../../../src/v2/models/suggestion.model.js'; -import SuggestionSchema from '../../../../src/v2/schema/suggestion.schema.js'; - -chaiUse(chaiAsPromised); - -const { attributes } = new Entity(SuggestionSchema).model.schema; - -describe('Suggestion', () => { - let suggestionInstance; - let mockElectroService; - let mockModelFactory; - let mockLogger; - - const mockRecord = { - suggestionId: 'sug12345', - opportunityId: 'op67890', - type: 'CODE_CHANGE', - status: 'NEW', - rank: 1, - data: { - info: 'sample data', - }, - kpiDeltas: { - conversionRate: 0.05, - }, - }; - - beforeEach(() => { - mockElectroService = { - entities: { - suggestion: { - model: { - name: 'suggestion', - schema: { attributes }, - original: { - references: {}, - }, - indexes: { - primary: { - pk: { - field: 'pk', - composite: ['suggestionId'], - }, - }, - }, - }, - patch: stub().returns({ - set: stub(), - }), - }, - }, - }; - - mockModelFactory = { - getCollection: stub(), - }; - - mockLogger = { - error: spy(), - }; - - suggestionInstance = new Suggestion( - mockElectroService, - mockModelFactory, - mockRecord, - mockLogger, - ); - }); - - describe('constructor', () => { - it('initializes the Suggestion instance correctly', () => { - expect(suggestionInstance).to.be.an('object'); - expect(suggestionInstance.record).to.deep.equal(mockRecord); - }); - }); - - describe('getOpportunityId and setOpportunityId', () => { - it('returns the Opportunity ID of the suggestion', () => { - expect(suggestionInstance.getOpportunityId()).to.equal('op67890'); - }); - - it('sets the Opportunity ID of the suggestion', () => { - suggestionInstance.setOpportunityId('ef39921f-9a02-41db-b491-02c98987d956'); - expect(suggestionInstance.record.opportunityId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); - }); - }); - - describe('getType', () => { - it('returns the type of the suggestion', () => { - expect(suggestionInstance.getType()).to.equal('CODE_CHANGE'); - }); - }); - - describe('getStatus and setStatus', () => { - it('returns the status of the suggestion', () => { - expect(suggestionInstance.getStatus()).to.equal('NEW'); - }); - - it('sets the status of the suggestion', () => { - suggestionInstance.setStatus('APPROVED'); - expect(suggestionInstance.record.status).to.equal('APPROVED'); - }); - }); - - describe('getRank and setRank', () => { - it('returns the rank of the suggestion', () => { - expect(suggestionInstance.getRank()).to.equal(1); - }); - - it('sets the rank of the suggestion', () => { - suggestionInstance.setRank(5); - expect(suggestionInstance.record.rank).to.equal(5); - }); - }); - - describe('getData and setData', () => { - it('returns additional data for the suggestion', () => { - expect(suggestionInstance.getData()).to.deep.equal({ info: 'sample data' }); - }); - - it('sets additional data for the suggestion', () => { - suggestionInstance.setData({ newInfo: 'updated data' }); - expect(suggestionInstance.record.data).to.deep.equal({ newInfo: 'updated data' }); - }); - }); - - describe('getKpiDeltas and setKpiDeltas', () => { - it('returns the KPI deltas for the suggestion', () => { - expect(suggestionInstance.getKpiDeltas()).to.deep.equal({ conversionRate: 0.05 }); - }); - - it('sets the KPI deltas for the suggestion', () => { - suggestionInstance.setKpiDeltas({ conversionRate: 0.1 }); - expect(suggestionInstance.record.kpiDeltas).to.deep.equal({ conversionRate: 0.1 }); - }); - }); -}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.collection.test.js new file mode 100755 index 00000000..989cadb3 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.collection.test.js @@ -0,0 +1,96 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import Suggestion from '../../../../../src/v2/models/suggestion/suggestion.model.js'; + +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SuggestionCollection', () => { + let instance; + + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let model; + let schema; + + const mockRecord = { + suggestionId: 's12345', + opportunityId: 'op67890', + data: { + title: 'Test Suggestion', + description: 'This is a test suggestion.', + }, + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + collection: instance, + model, + schema, + } = createElectroMocks(Suggestion, mockRecord)); + }); + + describe('constructor', () => { + it('initializes the SuggestionCollection instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.electroService).to.equal(mockElectroService); + expect(instance.entityRegistry).to.equal(mockEntityRegistry); + expect(instance.schema).to.equal(schema); + expect(instance.log).to.equal(mockLogger); + + expect(model).to.be.an('object'); + }); + }); + + describe('bulkUpdateStatus', () => { + it('updates the status of multiple suggestions', async () => { + const mockSuggestions = [model]; + const mockStatus = 'NEW'; + + await instance.bulkUpdateStatus(mockSuggestions, mockStatus); + + expect(mockElectroService.entities.suggestion.put.calledOnce).to.be.true; + expect(mockElectroService.entities.suggestion.put.firstCall.args[0]).to.deep.equal([{ + suggestionId: 's12345', + opportunityId: 'op67890', + data: { + title: 'Test Suggestion', + description: 'This is a test suggestion.', + }, + status: 'NEW', + }]); + }); + + it('throws an error if suggestions is not an array', async () => { + await expect(instance.bulkUpdateStatus({}, 'NEW')) + .to.be.rejectedWith('Suggestions must be an array'); + }); + + it('throws an error if status is not provided', async () => { + await expect(instance.bulkUpdateStatus([model], 'foo')) + .to.be.rejectedWith('Invalid status: foo. Must be one of: NEW, APPROVED, SKIPPED, FIXED, ERROR'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.model.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.model.test.js new file mode 100644 index 00000000..921f4673 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion/suggestion.model.test.js @@ -0,0 +1,122 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; + +import Suggestion from '../../../../../src/v2/models/suggestion/suggestion.model.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('SuggestionModel', () => { + let instance; + + let mockElectroService; + let mockRecord; + + beforeEach(() => { + mockRecord = { + suggestionId: 'sug12345', + opportunityId: 'op67890', + type: 'CODE_CHANGE', + status: 'NEW', + rank: 1, + data: { + info: 'sample data', + }, + kpiDeltas: { + conversionRate: 0.05, + }, + }; + + ({ + mockElectroService, + model: instance, + } = createElectroMocks(Suggestion, mockRecord)); + + mockElectroService.entities.patch = stub().returns({ set: stub() }); + }); + + describe('constructor', () => { + it('initializes the Suggestion instance correctly', () => { + expect(instance).to.be.an('object'); + expect(instance.record).to.deep.equal(mockRecord); + }); + }); + + describe('getOpportunityId and setOpportunityId', () => { + it('returns the Opportunity ID of the suggestion', () => { + expect(instance.getOpportunityId()).to.equal('op67890'); + }); + + it('sets the Opportunity ID of the suggestion', () => { + instance.setOpportunityId('ef39921f-9a02-41db-b491-02c98987d956'); + expect(instance.record.opportunityId).to.equal('ef39921f-9a02-41db-b491-02c98987d956'); + }); + }); + + describe('getType', () => { + it('returns the type of the suggestion', () => { + expect(instance.getType()).to.equal('CODE_CHANGE'); + }); + }); + + describe('getStatus and setStatus', () => { + it('returns the status of the suggestion', () => { + expect(instance.getStatus()).to.equal('NEW'); + }); + + it('sets the status of the suggestion', () => { + instance.setStatus('APPROVED'); + expect(instance.record.status).to.equal('APPROVED'); + }); + }); + + describe('getRank and setRank', () => { + it('returns the rank of the suggestion', () => { + expect(instance.getRank()).to.equal(1); + }); + + it('sets the rank of the suggestion', () => { + instance.setRank(5); + expect(instance.record.rank).to.equal(5); + }); + }); + + describe('getData and setData', () => { + it('returns additional data for the suggestion', () => { + expect(instance.getData()).to.deep.equal({ info: 'sample data' }); + }); + + it('sets additional data for the suggestion', () => { + instance.setData({ newInfo: 'updated data' }); + expect(instance.record.data).to.deep.equal({ newInfo: 'updated data' }); + }); + }); + + describe('getKpiDeltas and setKpiDeltas', () => { + it('returns the KPI deltas for the suggestion', () => { + expect(instance.getKpiDeltas()).to.deep.equal({ conversionRate: 0.05 }); + }); + + it('sets the KPI deltas for the suggestion', () => { + instance.setKpiDeltas({ conversionRate: 0.1 }); + expect(instance.record.kpiDeltas).to.deep.equal({ conversionRate: 0.1 }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util.js b/packages/spacecat-shared-data-access/test/unit/v2/util.js new file mode 100755 index 00000000..d9587461 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/util.js @@ -0,0 +1,107 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Entity } from 'electrodb'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { spy, stub } from 'sinon'; + +import EntityRegistry from '../../../src/v2/models/base/entity.registry.js'; +import { modelNameToEntityName } from '../../../src/v2/util/util.js'; + +export const createElectroMocks = (Model, record) => { + const entityName = modelNameToEntityName(Model.name); + const { + schema, + collection: Collection, + } = EntityRegistry.entities[modelNameToEntityName(Model.name)]; + const entity = new Entity(schema.toElectroDBSchema()); + + const mockLogger = { + debug: spy(), + error: spy(), + info: spy(), + warn: spy(), + }; + + const mockOperations = { + create: stub().returns({ + go: stub().resolves({ data: record }), + }), + delete: stub().returns({ + go: stub().resolves({}), + }), + patch: stub().returns({ + set: stub(), + }), + put: stub().returns({ + go: stub().resolves({}), + }), + query: { + all: stub().returns({ + between: stub().returns({ + go: () => ({ data: [] }), + }), + go: () => ({ data: [] }), + }), + bySomeKey: stub(), + primary: stub(), + byOpportunityId: stub(), + byOpportunityIdAndStatus: stub(), + }, + }; + + const mockEntityRegistry = { + log: mockLogger, + getCollection: stub().returns({ + schema: { + getReferenceByTypeAndTarget: stub().returns(null), + getModelName: stub().returns(Model.name), + indexes: { + primaryIndex: { + pk: { facets: ['testEntityId'] }, + sk: { facets: ['name', 'age'] }, + }, + }, + }, + }), + }; + + const mockElectroService = { + entities: { + [entityName]: { ...entity, ...mockOperations }, + }, + }; + + const model = new Model( + mockElectroService, + mockEntityRegistry, + schema, + record, + mockLogger, + ); + + const collection = new Collection( + mockElectroService, + mockEntityRegistry, + schema, + mockLogger, + ); + + return { + mockElectroService, + mockLogger, + mockEntityRegistry, + collection, + model, + schema, + }; +}; diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util/accessor.utils.test.js b/packages/spacecat-shared-data-access/test/unit/v2/util/accessor.utils.test.js new file mode 100755 index 00000000..3ec7872c --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/util/accessor.utils.test.js @@ -0,0 +1,222 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect, use as chaiUse } from 'chai'; +import sinon, { stub } from 'sinon'; +import sinonChai from 'sinon-chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { createAccessor } from '../../../../src/v2/util/accessor.utils.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +describe('Accessor Utils', () => { /* eslint-disable no-underscore-dangle */ + let mockLogger; + let mockContext; + let mockCollection; + + beforeEach(() => { + mockLogger = { + debug: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + }; + + mockContext = { log: mockLogger }; + + mockCollection = { + allByIndexKeys: stub().returns(Promise.resolve([{}])), + findById: stub().returns(Promise.resolve({})), + findByIndexKeys: stub().returns(Promise.resolve({})), + schema: { + getAttribute: stub().returns({ type: 'string' }), + }, + }; + }); + + describe('createAccessor', () => { + it('throws an error if no config is provided', () => { + expect(() => createAccessor()).to.throw('Config is required'); + expect(() => createAccessor([])).to.throw('Config is required'); + }); + + it('throws an error if collection is not provided', () => { + expect(() => createAccessor({ a: 1 })).to.throw('Collection is required'); + }); + + it('throws an error if context is not provided', () => { + expect(() => createAccessor({ collection: { a: 1 } })).to.throw('Context is required'); + }); + + it('throws an error if name is not provided', () => { + expect(() => createAccessor({ collection: { a: 1 }, context: { a: 1 } })).to.throw('Name is required'); + }); + + it('throws and error if requiredKeys is not an array', () => { + expect(() => createAccessor({ + collection: { a: 1 }, context: { a: 1 }, name: 'test', requiredKeys: 'test', + })).to.throw('Required keys must be an array'); + }); + + it('creates an accessor from config', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + }; + + createAccessor(config); + + expect(mockContext.test).to.be.a('function'); + expect(mockContext.test()).to.be.an('Promise'); + expect(mockContext._accessorCache).to.deep.equal({}); + }); + + it('does not create an accessor cache if existing', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + }; + mockContext._accessorCache = { a: 1 }; + + createAccessor(config); + + expect(mockContext._accessorCache).to.deep.equal({ a: 1 }); + }); + }); + + describe('call accessor', () => { + it('calling accessor calls findByIndexKeys', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + }; + + createAccessor(config); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal({}); + expect(mockCollection.schema.getAttribute).to.have.been.calledOnceWith('test'); + expect(mockCollection.findByIndexKeys).to.have.been.calledOnceWith({ test: 'test' }); + }); + + it('calling accessor calls allByIndexKeys', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + all: true, + }; + + createAccessor(config); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal([{}]); + expect(mockCollection.schema.getAttribute).to.have.been.calledOnceWith('test'); + expect(mockCollection.allByIndexKeys).to.have.been.calledOnceWith({ test: 'test' }); + }); + + it('calling accessor calls findBYId', async () => { + const config = { + collection: mockCollection, + context: mockContext, + foreignKey: { name: 'test', value: 'test' }, + name: 'test', + requiredKeys: ['test'], + byId: true, + }; + + createAccessor(config); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal({}); + expect(mockCollection.schema.getAttribute).to.not.have.been.called; + expect(mockCollection.findById).to.have.been.calledOnceWith('test'); + }); + + it('returns null when calling accessor byId with no value', async () => { + const config = { + collection: mockCollection, + context: mockContext, + foreignKey: { name: 'test' }, + name: 'test', + requiredKeys: ['test'], + byId: true, + }; + + createAccessor(config); + + await expect(mockContext.test('test')).to.be.eventually.null; + expect(mockCollection.schema.getAttribute).to.not.have.been.called; + expect(mockCollection.findById).to.not.have.been.called; + }); + + it('returns cached result if repeatedly called without args', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: [], + }; + + createAccessor(config); + + expect(mockContext._accessorCache).to.deep.equal({}); + + await expect(mockContext.test()).to.be.eventually.deep.equal({}); + + expect(mockContext._accessorCache).to.deep.equal({ 'test:_': {} }); + + await expect(mockContext.test()).to.be.eventually.deep.equal({}); + expect(mockCollection.schema.getAttribute).to.not.have.been.called; + expect(mockCollection.findByIndexKeys).to.have.been.calledOnceWith({}); + }); + + it('returns cached result if repeatedly called with same args', async () => { + const config = { + collection: mockCollection, + context: mockContext, + name: 'test', + requiredKeys: ['test'], + }; + + createAccessor(config); + + expect(mockContext._accessorCache).to.deep.equal({}); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal({}); + + expect(mockContext._accessorCache).to.deep.equal({ 'test:["test"]': {} }); + + await expect(mockContext.test('test')).to.be.eventually.deep.equal({}); + expect(mockCollection.schema.getAttribute).to.have.been.calledOnceWith('test'); + expect(mockCollection.findByIndexKeys).to.have.been.calledOnceWith({ test: 'test' }); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util/guards.test.js b/packages/spacecat-shared-data-access/test/unit/v2/util/guards.test.js index 9f67c1a8..f6a6a93f 100644 --- a/packages/spacecat-shared-data-access/test/unit/v2/util/guards.test.js +++ b/packages/spacecat-shared-data-access/test/unit/v2/util/guards.test.js @@ -18,6 +18,7 @@ import chaiAsPromised from 'chai-as-promised'; import { guardAny, guardArray, + guardBoolean, guardEnum, guardId, guardMap, @@ -70,13 +71,13 @@ describe('Guards', () => { }); it('allows specifying type as object', () => { - expect(() => guardArray('testProperty', [{ key: 'value' }, { anotherKey: 'anotherValue' }], 'TestEntity', 'object')) + expect(() => guardArray('testProperty', [{ key: 'value' }, { anotherKey: 'anotherValue' }], 'TestEntity', 'map')) .not.to.throw(); }); it('throws an error if array contains wrong type when expecting objects', () => { - expect(() => guardArray('testProperty', [{ key: 'value' }, 'notAnObject'], 'TestEntity', 'object')) - .to.throw('Validation failed in TestEntity: testProperty must contain items of type object'); + expect(() => guardArray('testProperty', [{ key: 'value' }, 'notAnObject'], 'TestEntity', 'map')) + .to.throw('Validation failed in TestEntity: testProperty must contain items of type map'); }); it('throws an error if an unsupported type is specified', () => { @@ -85,6 +86,43 @@ describe('Guards', () => { }); }); + describe('guardBoolean', () => { + it('throws an error if value is not a boolean', () => { + expect(() => guardBoolean('testProperty', 'notABoolean', 'TestEntity')) + .to.throw('Validation failed in TestEntity: testProperty must be a boolean'); + }); + + it('does not throw if value is a boolean', () => { + expect(() => guardBoolean('testProperty', true, 'TestEntity')) + .not.to.throw(); + }); + + it('does not throw if value is null and nullable is true', () => { + expect(() => guardBoolean('testProperty', null, 'TestEntity', true)) + .not.to.throw(); + }); + + it('does not throw if value is undefined and nullable is true', () => { + expect(() => guardBoolean('testProperty', undefined, 'TestEntity', true)) + .not.to.throw(); + }); + + it('throws an error if value is undefined and nullable is false', () => { + expect(() => guardBoolean('testProperty', undefined, 'TestEntity', false)) + .to.throw('Validation failed in TestEntity: testProperty must be a boolean'); + }); + + it('throws an error if value is null and nullable is false', () => { + expect(() => guardBoolean('testProperty', null, 'TestEntity', false)) + .to.throw('Validation failed in TestEntity: testProperty must be a boolean'); + }); + + it('throws an error if value is an empty string and nullable is false', () => { + expect(() => guardBoolean('testProperty', '', 'TestEntity', false)) + .to.throw('Validation failed in TestEntity: testProperty must be a boolean'); + }); + }); + describe('guardSet', () => { it('throws an error if value is not an array', () => { expect(() => guardSet('testProperty', 'notArray', 'TestEntity')) @@ -139,8 +177,8 @@ describe('Guards', () => { }); it('throws an error if array contains wrong type when expecting objects', () => { - expect(() => guardSet('testProperty', [{}, { a: 'b' }, 3], 'TestEntity', 'object')) - .to.throw('Validation failed in TestEntity: testProperty must contain items of type object'); + expect(() => guardSet('testProperty', [{}, { a: 'b' }, 3], 'TestEntity', 'map')) + .to.throw('Validation failed in TestEntity: testProperty must contain items of type map'); }); it('throws an error if array contains wrong type when expecting strings', () => { diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js b/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js index 31673b14..6d57a585 100755 --- a/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js +++ b/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js @@ -12,14 +12,23 @@ /* eslint-env mocha */ +// eslint-disable-next-line max-classes-per-file +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + import { expect, use as chaiUse } from 'chai'; import sinon from 'sinon'; import chaiAsPromised from 'chai-as-promised'; import Patcher from '../../../../src/v2/util/patcher.js'; +import Schema from '../../../../src/v2/models/base/schema.js'; +import BaseModel from '../../../../src/v2/models/base/base.model.js'; +import BaseCollection from '../../../../src/v2/models/base/base.collection.js'; chaiUse(chaiAsPromised); +const MockModel = class MockEntityModel extends BaseModel {}; +const MockCollection = class MockEntityCollection extends BaseCollection {}; + describe('Patcher', () => { let patcher; let mockEntity; @@ -28,18 +37,19 @@ describe('Patcher', () => { beforeEach(() => { mockEntity = { model: { - name: 'TestEntity', + entity: 'MockModel', schema: { attributes: { - name: { type: 'string' }, - age: { type: 'number' }, - tags: { type: 'set', items: { type: 'string' } }, - status: { type: 'enum', enumArray: ['active', 'inactive'] }, - referenceId: { type: 'string' }, - metadata: { type: 'map' }, - profile: { type: 'any' }, - nickNames: { type: 'list', items: { type: 'string' } }, - settings: { type: 'any', required: true }, + name: { type: 'string', name: 'name' }, + age: { type: 'number', name: 'age' }, + tags: { type: 'set', name: 'tags', items: { type: 'string' } }, + status: { type: 'enum', name: 'status', enumArray: ['active', 'inactive'] }, + referenceId: { type: 'string', name: 'referenceId' }, + metadata: { type: 'map', name: 'metadata' }, + profile: { type: 'any', name: 'profile' }, + nickNames: { type: 'list', name: 'nickNames', items: { type: 'string' } }, + settings: { type: 'any', name: 'settings', required: true }, + isActive: { type: 'boolean', name: 'isActive' }, }, }, indexes: { @@ -50,6 +60,7 @@ describe('Patcher', () => { }, }, patch: sinon.stub().returns({ + composite: sinon.stub().returnsThis(), set: sinon.stub().returnsThis(), go: sinon.stub().resolves(), }), @@ -64,7 +75,20 @@ describe('Patcher', () => { referenceId: '456', }; - patcher = new Patcher(mockEntity, mockRecord); + const schema = new Schema( + MockModel, + MockCollection, + { + serviceName: 'service', + schemaVersion: 1, + attributes: mockEntity.model.schema.attributes, + indexes: mockEntity.model.indexes, + model: mockEntity.model, + references: [], + }, + ); + + patcher = new Patcher(mockEntity, schema, mockRecord); }); afterEach(() => { @@ -73,7 +97,7 @@ describe('Patcher', () => { it('patches a string value', () => { patcher.patchValue('name', 'UpdatedName'); - expect(mockEntity.patch().set.calledWith({ name: 'UpdatedName', age: 25 })).to.be.true; + expect(mockEntity.patch().set.calledWith({ name: 'UpdatedName' })).to.be.true; expect(mockRecord.name).to.equal('UpdatedName'); }); @@ -90,7 +114,7 @@ describe('Patcher', () => { it('throws error for unsupported enum value', () => { expect(() => patcher.patchValue('status', 'unknown')) - .to.throw('Validation failed in testentity: status must be one of active,inactive'); + .to.throw('Validation failed in mockEntityModel: status must be one of active,inactive'); }); it('patches a reference id with proper validation', () => { @@ -100,14 +124,15 @@ describe('Patcher', () => { it('throws error for non-existent property', () => { expect(() => patcher.patchValue('nonExistent', 'value')) - .to.throw('Property nonExistent does not exist on entity testentity.'); + .to.throw('Property nonExistent does not exist on entity mockEntityModel.'); }); it('tracks updates', () => { patcher.patchValue('name', 'UpdatedName'); expect(patcher.hasUpdates()).to.be.true; - expect(patcher.getUpdates()).to.deep.equal({ name: 'UpdatedName' }); + expect(patcher.getUpdates().name.previous).to.deep.equal('Test'); + expect(patcher.getUpdates().name.current).to.deep.equal('UpdatedName'); }); it('saves the record', async () => { @@ -116,7 +141,7 @@ describe('Patcher', () => { await patcher.save(); expect(mockEntity.patch().go.calledOnce).to.be.true; - expect(mockRecord.updatedAt).to.be.a('number'); + expect(isIsoDate(mockRecord.updatedAt)).to.be.true; }); it('does not save if there are no updates', async () => { @@ -137,7 +162,7 @@ describe('Patcher', () => { it('throws error for invalid set attribute', () => { expect(() => patcher.patchValue('tags', ['tag1', 123])) - .to.throw('Validation failed in testentity: tags must contain items of type string'); + .to.throw('Validation failed in mockEntityModel: tags must contain items of type string'); }); it('validates and patches a number attribute', () => { @@ -147,7 +172,7 @@ describe('Patcher', () => { it('throws error for invalid number attribute', () => { expect(() => patcher.patchValue('age', 'notANumber')) - .to.throw('Validation failed in testentity: age must be a number'); + .to.throw('Validation failed in mockEntityModel: age must be a number'); }); it('validates and patch a map attribute', () => { @@ -157,7 +182,7 @@ describe('Patcher', () => { it('throws error for invalid map attribute', () => { expect(() => patcher.patchValue('metadata', 'notAMap')) - .to.throw('Validation failed in testentity: metadata must be an object'); + .to.throw('Validation failed in mockEntityModel: metadata must be an object'); }); it('validates and patches an any attribute', () => { @@ -167,12 +192,17 @@ describe('Patcher', () => { it('throws error for undefined any attribute', () => { expect(() => patcher.patchValue('settings', undefined)) - .to.throw('Validation failed in testentity: settings is required'); + .to.throw('Validation failed in mockEntityModel: settings is required'); }); it('throws error for null any attribute', () => { expect(() => patcher.patchValue('settings', null)) - .to.throw('Validation failed in testentity: settings is required'); + .to.throw('Validation failed in mockEntityModel: settings is required'); + }); + + it('validates and patches a boolean attribute', () => { + patcher.patchValue('isActive', true); + expect(mockRecord.isActive).to.be.true; }); it('validates and patches a list attribute', () => { @@ -182,11 +212,11 @@ describe('Patcher', () => { it('throws error for invalid list attribute', () => { expect(() => patcher.patchValue('nickNames', 'notAList')) - .to.throw('Validation failed in testentity: nickNames must be an array'); + .to.throw('Validation failed in mockEntityModel: nickNames must be an array'); }); it('throws error for invalid list attribute items', () => { expect(() => patcher.patchValue('nickNames', ['name1', 123])) - .to.throw('Validation failed in testentity: nickNames must contain items of type string'); + .to.throw('Validation failed in mockEntityModel: nickNames must contain items of type string'); }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util/util.test.js b/packages/spacecat-shared-data-access/test/unit/v2/util/util.test.js new file mode 100644 index 00000000..edfdaa77 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/v2/util/util.test.js @@ -0,0 +1,220 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +// utils.test.js +// This suite tests all utility functions from the provided utils file. +// Requires Mocha for tests, Chai for assertions, and Sinon for spying/stubbing. + +import { expect } from 'chai'; +import { + capitalize, + collectionNameToEntityName, + decapitalize, + entityNameToAllPKValue, + entityNameToCollectionName, + entityNameToIdName, + idNameToEntityName, + incrementVersion, + isNonEmptyArray, + keyNamesToIndexName, + modelNameToEntityName, + referenceToBaseMethodName, + sanitizeIdAndAuditFields, + sanitizeTimestamps, +} from '../../../../src/v2/util/util.js'; +import Reference from '../../../../src/v2/models/base/reference.js'; + +describe('Utilities', () => { + describe('capitalize', () => { + it('Convert first character to uppercase', () => { + expect(capitalize('hello')).to.equal('Hello'); + }); + + it('Return empty string if input empty', () => { + expect(capitalize('')).to.equal(''); + }); + + it('Not alter already capitalized strings', () => { + expect(capitalize('Hello')).to.equal('Hello'); + }); + }); + + describe('decapitalize', () => { + it('Convert first character to lowercase', () => { + expect(decapitalize('Hello')).to.equal('hello'); + }); + + it('Return empty string if input empty', () => { + expect(decapitalize('')).to.equal(''); + }); + + it('Not alter already lowercased strings', () => { + expect(decapitalize('hello')).to.equal('hello'); + }); + }); + + describe('collectionNameToEntityName', () => { + it('Remove "Collection" suffix from a given string', () => { + expect(collectionNameToEntityName('UserCollection')).to.equal('User'); + }); + + it('Return the original string if no "Collection" present', () => { + expect(collectionNameToEntityName('User')).to.equal('User'); + }); + }); + + describe('entityNameToCollectionName', () => { + it('Append "Collection" to a singular form of entity name', () => { + expect(entityNameToCollectionName('User')).to.equal('UserCollection'); + }); + + it('Handle plural entity names by converting to singular first', () => { + expect(entityNameToCollectionName('Users')).to.equal('UserCollection'); + }); + }); + + describe('entityNameToIdName', () => { + it('Convert entityName to a lowercaseId format', () => { + expect(entityNameToIdName('User')).to.equal('userId'); + }); + + it('Handle already lowercase entityName', () => { + expect(entityNameToIdName('user')).to.equal('userId'); + }); + }); + + describe('entityNameToAllPKValue', () => { + it('Convert entity name to ALL_ upper plural form', () => { + expect(entityNameToAllPKValue('User')).to.equal('ALL_USERS'); + }); + + it('Handle already plural entity name', () => { + expect(entityNameToAllPKValue('Users')).to.equal('ALL_USERS'); + }); + }); + + describe('referenceToBaseMethodName', () => { + it('Generate "get" + pluralized capitalized target if type is has_many', () => { + const reference = new Reference('has_many', 'users'); + expect(referenceToBaseMethodName(reference)).to.equal('getUsers'); + }); + + it('Generate "get" + singular capitalized target if type is not has_many', () => { + const reference = new Reference('has_one', 'users'); + expect(referenceToBaseMethodName(reference)).to.equal('getUser'); + }); + + it('Handle already capitalized target', () => { + const reference = new Reference('has_many', 'User'); + expect(referenceToBaseMethodName(reference)).to.equal('getUsers'); + }); + }); + + describe('idNameToEntityName', () => { + it('Convert idName to singular, capitalized entityName', () => { + expect(idNameToEntityName('userId')).to.equal('User'); + }); + + it('Handle plural-like idNames', () => { + expect(idNameToEntityName('usersId')).to.equal('User'); + }); + }); + + describe('incrementVersion', () => { + it('Increment version by 1 if it is an integer', () => { + expect(incrementVersion(1)).to.equal(2); + }); + + it('Return 1 if version is not an integer', () => { + expect(incrementVersion('not-a-number')).to.equal(1); + }); + + it('Return 1 if version is undefined', () => { + expect(incrementVersion(undefined)).to.equal(1); + }); + }); + + describe('isNonEmptyArray', () => { + it('Return true if value is a non-empty array', () => { + expect(isNonEmptyArray([1, 2, 3])).to.be.true; + }); + + it('Return false if value is an empty array', () => { + expect(isNonEmptyArray([])).to.be.false; + }); + + it('Return false if value is not an array', () => { + expect(isNonEmptyArray({})).to.be.false; + }); + }); + + describe('keyNamesToIndexName', () => { + it('Create index name by capitalizing and joining key names', () => { + expect(keyNamesToIndexName(['user', 'status'])).to.equal('byUserAndStatus'); + }); + + it('Handle single key name', () => { + expect(keyNamesToIndexName(['user'])).to.equal('byUser'); + }); + }); + + describe('modelNameToEntityName', () => { + it('Decapitalize model name', () => { + expect(modelNameToEntityName('UserModel')).to.equal('userModel'); + }); + + it('Handle already lowercase', () => { + expect(modelNameToEntityName('usermodel')).to.equal('usermodel'); + }); + }); + + describe('sanitizeTimestamps', () => { + it('Remove createdAt and updatedAt fields', () => { + const data = { foo: 'bar', createdAt: 'yesterday', updatedAt: 'today' }; + expect(sanitizeTimestamps(data)).to.deep.equal({ foo: 'bar' }); + }); + + it('Return object unchanged if no timestamps present', () => { + const data = { foo: 'bar' }; + expect(sanitizeTimestamps(data)).to.deep.equal({ foo: 'bar' }); + }); + }); + + describe('sanitizeIdAndAuditFields', () => { + it('Remove entity ID and timestamps', () => { + const data = { + userId: '123', + foo: 'bar', + createdAt: 'yesterday', + updatedAt: 'today', + }; + expect(sanitizeIdAndAuditFields('User', data)).to.deep.equal({ foo: 'bar' }); + }); + + it('Handle entityName that results in different idName', () => { + const data = { + productId: 'abc', + name: 'Gadget', + createdAt: 'yesterday', + updatedAt: 'today', + }; + expect(sanitizeIdAndAuditFields('Product', data)).to.deep.equal({ name: 'Gadget' }); + }); + + it('Return object unchanged if no ID or timestamps present', () => { + const data = { foo: 'bar' }; + expect(sanitizeIdAndAuditFields('User', data)).to.deep.equal({ foo: 'bar' }); + }); + }); +}); diff --git a/packages/spacecat-shared-http-utils/src/auth/authentication-manager.js b/packages/spacecat-shared-http-utils/src/auth/authentication-manager.js index c835c9f3..929ba913 100644 --- a/packages/spacecat-shared-http-utils/src/auth/authentication-manager.js +++ b/packages/spacecat-shared-http-utils/src/auth/authentication-manager.js @@ -72,7 +72,7 @@ export default class AuthenticationManager { /** * Create an instance of AuthenticationManager. * @param {Array} handlers - The handlers to be used for authentication - * @param {Object} log - The logger object + * @param {Object} log - The log object * @return {AuthenticationManager} The authentication manager */ static create(handlers, log) { diff --git a/packages/spacecat-shared-http-utils/test/auth/handlers/abstract.test.js b/packages/spacecat-shared-http-utils/test/auth/handlers/abstract.test.js index 44cbe0a3..69f840f8 100644 --- a/packages/spacecat-shared-http-utils/test/auth/handlers/abstract.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/handlers/abstract.test.js @@ -47,7 +47,7 @@ describe('AbstractHandler', () => { expect(() => new AbstractHandler('TestHandler', logStub)).to.throw(TypeError, 'Cannot construct AbstractHandler instances directly'); }); - it('sets the name and logger properties correctly', () => { + it('sets the name and log properties correctly', () => { const handler = new ConcreteHandler(logStub); expect(handler.name).to.equal('ConcreteHandler'); expect(handler.logger).to.equal(logStub); diff --git a/packages/spacecat-shared-http-utils/test/auth/handlers/ims.test.js b/packages/spacecat-shared-http-utils/test/auth/handlers/ims.test.js index 8b75cbd5..a07aa1db 100644 --- a/packages/spacecat-shared-http-utils/test/auth/handlers/ims.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/handlers/ims.test.js @@ -58,7 +58,7 @@ describe('AdobeImsHandler', () => { expect(handler).to.be.instanceof(AbstractHandler); }); - it('sets the name and logger properties correctly', () => { + it('sets the name and log properties correctly', () => { expect(handler.name).to.equal('ims'); expect(handler.logger).to.equal(logStub); }); diff --git a/packages/spacecat-shared-http-utils/test/auth/handlers/legacy-api-keys.test.js b/packages/spacecat-shared-http-utils/test/auth/handlers/legacy-api-keys.test.js index f5d2ff65..53cdd03c 100644 --- a/packages/spacecat-shared-http-utils/test/auth/handlers/legacy-api-keys.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/handlers/legacy-api-keys.test.js @@ -56,7 +56,7 @@ describe('LegacyApiKeyHandler', () => { expect(handler).to.be.instanceof(AbstractHandler); }); - it('should set the name and logger properties correctly', () => { + it('should set the name and log properties correctly', () => { expect(handler.name).to.equal('legacyApiKey'); expect(handler.logger).to.equal(logStub); }); diff --git a/packages/spacecat-shared-http-utils/test/auth/handlers/scoped-api-key.test.js b/packages/spacecat-shared-http-utils/test/auth/handlers/scoped-api-key.test.js index 92690e43..e7b9829e 100644 --- a/packages/spacecat-shared-http-utils/test/auth/handlers/scoped-api-key.test.js +++ b/packages/spacecat-shared-http-utils/test/auth/handlers/scoped-api-key.test.js @@ -73,7 +73,7 @@ describe('ScopedApiKeyHandler', () => { expect(handler).to.be.instanceof(AbstractHandler); }); - it('should set the name and logger properties correctly', () => { + it('should set the name and log properties correctly', () => { expect(handler.name).to.equal('scopedApiKey'); expect(handler.logger).to.equal(logStub); }); diff --git a/packages/spacecat-shared-ims-client/README.md b/packages/spacecat-shared-ims-client/README.md index 7ae53b0d..716ce226 100644 --- a/packages/spacecat-shared-ims-client/README.md +++ b/packages/spacecat-shared-ims-client/README.md @@ -25,7 +25,7 @@ import ImsClient from 'path/to/ImsClient'; ### Creating an ImsClient Instance -To create an instance of the ImsClient, you need to provide a context object containing the necessary environment configurations and an optional logger. +To create an instance of the ImsClient, you need to provide a context object containing the necessary environment configurations and an optional log. ```javascript const context = { @@ -35,7 +35,7 @@ const context = { IMS_CLIENT_CODE: 'yourClientCode', IMS_CLIENT_SECRET: 'yourClientSecret', }, - log: console, // Optional: Custom logger can be provided + log: console, // Optional: Custom log can be provided }; const imsClient = ImsClient.createFrom(context); diff --git a/packages/spacecat-shared-slack-client/src/clients/base-slack-client.js b/packages/spacecat-shared-slack-client/src/clients/base-slack-client.js index b339eabf..261127e0 100644 --- a/packages/spacecat-shared-slack-client/src/clients/base-slack-client.js +++ b/packages/spacecat-shared-slack-client/src/clients/base-slack-client.js @@ -79,7 +79,7 @@ export default class BaseSlackClient { * @param {object} opsConfig The ops configuration. * @param {string} opsConfig.opsChannelId The ID of the ops channel. * @param {string[]} opsConfig.admins The list of admin user IDs. - * @param {object} log - logger + * @param {object} log - log */ constructor(token, opsConfig, log) { this.client = new WebClient(token); diff --git a/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js b/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js index b7f612f6..4382181b 100644 --- a/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js +++ b/packages/spacecat-shared-slack-client/src/clients/elevated-slack-client.js @@ -59,7 +59,7 @@ export default class ElevatedSlackClient extends BaseSlackClient { * @param {object} opsConfig The ops configuration. * @param {string} opsConfig.opsChannelId The ID of the ops channel. * @param {string[]} opsConfig.admins The list of admin user IDs. - * @param {Object} log The logger object. + * @param {Object} log The log object. */ constructor(token, opsConfig, log) { super(token, opsConfig, log); From beb094798e3d12b8212d72d57c995ccac3ff4e88 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 18 Dec 2024 09:23:13 +0000 Subject: [PATCH 2/8] chore(release): 1.60.0 [skip ci] # [@adobe/spacecat-shared-data-access-v1.60.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.59.2...@adobe/spacecat-shared-data-access-v1.60.0) (2024-12-18) ### Features * migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) --- packages/spacecat-shared-data-access/CHANGELOG.md | 7 +++++++ packages/spacecat-shared-data-access/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/spacecat-shared-data-access/CHANGELOG.md b/packages/spacecat-shared-data-access/CHANGELOG.md index 7f3c2fd4..633dd107 100755 --- a/packages/spacecat-shared-data-access/CHANGELOG.md +++ b/packages/spacecat-shared-data-access/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@adobe/spacecat-shared-data-access-v1.60.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.59.2...@adobe/spacecat-shared-data-access-v1.60.0) (2024-12-18) + + +### Features + +* migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) + # [@adobe/spacecat-shared-data-access-v1.59.2](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.59.1...@adobe/spacecat-shared-data-access-v1.59.2) (2024-12-14) diff --git a/packages/spacecat-shared-data-access/package.json b/packages/spacecat-shared-data-access/package.json index b4aebdba..337fde5a 100644 --- a/packages/spacecat-shared-data-access/package.json +++ b/packages/spacecat-shared-data-access/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/spacecat-shared-data-access", - "version": "1.59.2", + "version": "1.60.0", "description": "Shared modules of the Spacecat Services - Data Access", "type": "module", "engines": { From 4d493f5c2e086583cbcad751eeb8e56b294e3f3d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 18 Dec 2024 09:23:58 +0000 Subject: [PATCH 3/8] chore(release): 1.8.0 [skip ci] # [@adobe/spacecat-shared-http-utils-v1.8.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-http-utils-v1.7.3...@adobe/spacecat-shared-http-utils-v1.8.0) (2024-12-18) ### Features * migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) --- packages/spacecat-shared-http-utils/CHANGELOG.md | 7 +++++++ packages/spacecat-shared-http-utils/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/spacecat-shared-http-utils/CHANGELOG.md b/packages/spacecat-shared-http-utils/CHANGELOG.md index 3c1be0bf..74bfb573 100644 --- a/packages/spacecat-shared-http-utils/CHANGELOG.md +++ b/packages/spacecat-shared-http-utils/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@adobe/spacecat-shared-http-utils-v1.8.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-http-utils-v1.7.3...@adobe/spacecat-shared-http-utils-v1.8.0) (2024-12-18) + + +### Features + +* migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) + # [@adobe/spacecat-shared-http-utils-v1.7.3](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-http-utils-v1.7.2...@adobe/spacecat-shared-http-utils-v1.7.3) (2024-12-07) diff --git a/packages/spacecat-shared-http-utils/package.json b/packages/spacecat-shared-http-utils/package.json index 2f67f8fc..57a976ab 100644 --- a/packages/spacecat-shared-http-utils/package.json +++ b/packages/spacecat-shared-http-utils/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/spacecat-shared-http-utils", - "version": "1.7.3", + "version": "1.8.0", "description": "Shared modules of the Spacecat Services - HTTP Utils", "type": "module", "engines": { From 1f0c99b41e47a4f5bc993c5c228b19e4f6ec97f2 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 18 Dec 2024 09:24:38 +0000 Subject: [PATCH 4/8] chore(release): 1.4.0 [skip ci] # [@adobe/spacecat-shared-ims-client-v1.4.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-ims-client-v1.3.27...@adobe/spacecat-shared-ims-client-v1.4.0) (2024-12-18) ### Features * migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) --- packages/spacecat-shared-ims-client/CHANGELOG.md | 7 +++++++ packages/spacecat-shared-ims-client/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/spacecat-shared-ims-client/CHANGELOG.md b/packages/spacecat-shared-ims-client/CHANGELOG.md index c130aef6..7c26c098 100644 --- a/packages/spacecat-shared-ims-client/CHANGELOG.md +++ b/packages/spacecat-shared-ims-client/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@adobe/spacecat-shared-ims-client-v1.4.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-ims-client-v1.3.27...@adobe/spacecat-shared-ims-client-v1.4.0) (2024-12-18) + + +### Features + +* migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) + # [@adobe/spacecat-shared-ims-client-v1.3.27](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-ims-client-v1.3.26...@adobe/spacecat-shared-ims-client-v1.3.27) (2024-12-08) diff --git a/packages/spacecat-shared-ims-client/package.json b/packages/spacecat-shared-ims-client/package.json index 654de6e5..a40d5a7d 100644 --- a/packages/spacecat-shared-ims-client/package.json +++ b/packages/spacecat-shared-ims-client/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/spacecat-shared-ims-client", - "version": "1.3.27", + "version": "1.4.0", "description": "Shared modules of the Spacecat Services - IMS Client", "type": "module", "engines": { From c277b5d3b6b23dc12480c019b6fe7ead183c3915 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 18 Dec 2024 09:25:08 +0000 Subject: [PATCH 5/8] chore(release): 1.4.0 [skip ci] # [@adobe/spacecat-shared-slack-client-v1.4.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-slack-client-v1.3.28...@adobe/spacecat-shared-slack-client-v1.4.0) (2024-12-18) ### Features * migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) --- packages/spacecat-shared-slack-client/CHANGELOG.md | 7 +++++++ packages/spacecat-shared-slack-client/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/spacecat-shared-slack-client/CHANGELOG.md b/packages/spacecat-shared-slack-client/CHANGELOG.md index feb246ac..a7f5ddab 100644 --- a/packages/spacecat-shared-slack-client/CHANGELOG.md +++ b/packages/spacecat-shared-slack-client/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@adobe/spacecat-shared-slack-client-v1.4.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-slack-client-v1.3.28...@adobe/spacecat-shared-slack-client-v1.4.0) (2024-12-18) + + +### Features + +* migrate entities to electrodb ([#484](https://github.com/adobe/spacecat-shared/issues/484)) ([e9a6310](https://github.com/adobe/spacecat-shared/commit/e9a6310dbdea4d44562432b794aa1e287ba9428d)) + # [@adobe/spacecat-shared-slack-client-v1.3.28](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-slack-client-v1.3.27...@adobe/spacecat-shared-slack-client-v1.3.28) (2024-12-08) diff --git a/packages/spacecat-shared-slack-client/package.json b/packages/spacecat-shared-slack-client/package.json index e6ce7655..e5345b4a 100644 --- a/packages/spacecat-shared-slack-client/package.json +++ b/packages/spacecat-shared-slack-client/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/spacecat-shared-slack-client", - "version": "1.3.28", + "version": "1.4.0", "description": "Shared modules of the Spacecat Services - Slack Client", "type": "module", "engines": { From a21b1887d6872092679f9c5d02452e79711955e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominique=20J=C3=A4ggi?= <1872195+solaris007@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:22:16 +0100 Subject: [PATCH 6/8] fix: removeElectroProperties (#498) --- .../src/v2/models/base/base.collection.js | 2 +- .../src/v2/util/util.js | 20 ++++++++++++++++++ .../test/it/util/util.js | 21 ++----------------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js b/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js index f906000f..663b0331 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js +++ b/packages/spacecat-shared-data-access/src/v2/models/base/base.collection.js @@ -18,7 +18,6 @@ import { import { ElectroValidationError } from 'electrodb'; -import { removeElectroProperties } from '../../../../test/it/util/util.js'; import { createAccessors } from '../../util/accessor.utils.js'; import ValidationError from '../../errors/validation.error.js'; import { guardId } from '../../util/guards.js'; @@ -26,6 +25,7 @@ import { entityNameToAllPKValue, isNonEmptyArray, keyNamesToIndexName, + removeElectroProperties, } from '../../util/util.js'; import { INDEX_TYPES } from './constants.js'; diff --git a/packages/spacecat-shared-data-access/src/v2/util/util.js b/packages/spacecat-shared-data-access/src/v2/util/util.js index 04792a20..1ec8f81c 100644 --- a/packages/spacecat-shared-data-access/src/v2/util/util.js +++ b/packages/spacecat-shared-data-access/src/v2/util/util.js @@ -46,6 +46,25 @@ const keyNamesToMethodName = (keyNames, prefix) => prefix + keyNames.map(capital const modelNameToEntityName = (modelName) => decapitalize(modelName); +const removeElectroProperties = (record) => { /* eslint-disable no-underscore-dangle */ + const cleanedRecord = { ...record }; + + delete cleanedRecord.sk; + delete cleanedRecord.pk; + delete cleanedRecord.gsi1pk; + delete cleanedRecord.gsi1sk; + delete cleanedRecord.gsi2pk; + delete cleanedRecord.gsi2sk; + delete cleanedRecord.gsi3pk; + delete cleanedRecord.gsi3sk; + delete cleanedRecord.gsi4pk; + delete cleanedRecord.gsi4sk; + delete cleanedRecord.__edb_e__; + delete cleanedRecord.__edb_v__; + + return cleanedRecord; +}; + const sanitizeTimestamps = (data) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { createdAt, updatedAt, ...rest } = data; @@ -79,6 +98,7 @@ export { keyNamesToMethodName, modelNameToEntityName, referenceToBaseMethodName, + removeElectroProperties, sanitizeIdAndAuditFields, sanitizeTimestamps, }; diff --git a/packages/spacecat-shared-data-access/test/it/util/util.js b/packages/spacecat-shared-data-access/test/it/util/util.js index 0bf08dd7..a6995c0d 100755 --- a/packages/spacecat-shared-data-access/test/it/util/util.js +++ b/packages/spacecat-shared-data-access/test/it/util/util.js @@ -9,6 +9,8 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +import { removeElectroProperties } from '../../../src/v2/util/util.js'; + const randomDate = (start, end) => { if (start.getTime() >= end.getTime()) { throw new Error('start must be before end'); @@ -24,25 +26,6 @@ const getRandomDecimal = (precision) => parseFloat(Math.random().toFixed(precisi // Generates a random integer up to a given maximum const getRandomInt = (max) => Math.floor(Math.random() * max); -const removeElectroProperties = (record) => { /* eslint-disable no-underscore-dangle */ - const cleanedRecord = { ...record }; - - delete cleanedRecord.sk; - delete cleanedRecord.pk; - delete cleanedRecord.gsi1pk; - delete cleanedRecord.gsi1sk; - delete cleanedRecord.gsi2pk; - delete cleanedRecord.gsi2sk; - delete cleanedRecord.gsi3pk; - delete cleanedRecord.gsi3sk; - delete cleanedRecord.gsi4pk; - delete cleanedRecord.gsi4sk; - delete cleanedRecord.__edb_e__; - delete cleanedRecord.__edb_v__; - - return cleanedRecord; -}; - const sanitizeRecord = (record, idName) => { const sanitizedRecord = removeElectroProperties({ ...record }); From 50881f975c10da1d5bf0f013772fd840cda08926 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 18 Dec 2024 14:25:22 +0000 Subject: [PATCH 7/8] chore(release): 1.60.1 [skip ci] # [@adobe/spacecat-shared-data-access-v1.60.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.60.0...@adobe/spacecat-shared-data-access-v1.60.1) (2024-12-18) ### Bug Fixes * removeElectroProperties ([#498](https://github.com/adobe/spacecat-shared/issues/498)) ([a21b188](https://github.com/adobe/spacecat-shared/commit/a21b1887d6872092679f9c5d02452e79711955e7)) --- packages/spacecat-shared-data-access/CHANGELOG.md | 7 +++++++ packages/spacecat-shared-data-access/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/spacecat-shared-data-access/CHANGELOG.md b/packages/spacecat-shared-data-access/CHANGELOG.md index 633dd107..a6522588 100755 --- a/packages/spacecat-shared-data-access/CHANGELOG.md +++ b/packages/spacecat-shared-data-access/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@adobe/spacecat-shared-data-access-v1.60.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.60.0...@adobe/spacecat-shared-data-access-v1.60.1) (2024-12-18) + + +### Bug Fixes + +* removeElectroProperties ([#498](https://github.com/adobe/spacecat-shared/issues/498)) ([a21b188](https://github.com/adobe/spacecat-shared/commit/a21b1887d6872092679f9c5d02452e79711955e7)) + # [@adobe/spacecat-shared-data-access-v1.60.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.59.2...@adobe/spacecat-shared-data-access-v1.60.0) (2024-12-18) diff --git a/packages/spacecat-shared-data-access/package.json b/packages/spacecat-shared-data-access/package.json index 337fde5a..c1f0cc5e 100644 --- a/packages/spacecat-shared-data-access/package.json +++ b/packages/spacecat-shared-data-access/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/spacecat-shared-data-access", - "version": "1.60.0", + "version": "1.60.1", "description": "Shared modules of the Spacecat Services - Data Access", "type": "module", "engines": { From e2b5192e7cd03c9e85371eb2cd4ba8d3b4540e84 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:08:19 +0100 Subject: [PATCH 8/8] chore(deps): update npm to v11 (#496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [npm](https://docs.npmjs.com/) ([source](https://redirect.github.com/npm/cli)) | [`>=10.0.0 <11.0.0` -> `>=10.0.0 <12.0.0`](https://renovatebot.com/diffs/npm/npm/10.9.0/11.0.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/npm/11.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/npm/11.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/npm/10.9.0/11.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/npm/10.9.0/11.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
npm/cli (npm) ### [`v11.0.0`](https://redirect.github.com/npm/cli/blob/HEAD/CHANGELOG.md#1100-2024-12-16) [Compare Source](https://redirect.github.com/npm/cli/compare/v10.9.2...v11.0.0) ##### Documentation - [`8a911da`](https://redirect.github.com/npm/cli/commit/8a911da452b9785bcd051778570beeb2d8b27421) [#​7963](https://redirect.github.com/npm/cli/pull/7963) ls: removed design change pending section note ([#​7963](https://redirect.github.com/npm/cli/issues/7963)) ([@​milaninfy](https://redirect.github.com/milaninfy)) ##### Dependencies - [`5319e48`](https://redirect.github.com/npm/cli/commit/5319e48a5a91768dccdfe728392dc2040e7ce27e) [#​7973](https://redirect.github.com/npm/cli/pull/7973) remove unnecessary sprintf-js files in node_modules ([#​7973](https://redirect.github.com/npm/cli/issues/7973)) - [`d369c77`](https://redirect.github.com/npm/cli/commit/d369c7716d753580da708723a2a4f8b3be767cb1) [#​7976](https://redirect.github.com/npm/cli/pull/7976) `socks-proxy-agent@8.0.5` - [`3b2951a`](https://redirect.github.com/npm/cli/commit/3b2951a3ba1521b9866d9b33960aa3307d4f31dd) [#​7976](https://redirect.github.com/npm/cli/pull/7976) `https-proxy-agent@7.0.6` - [`a598b7b`](https://redirect.github.com/npm/cli/commit/a598b7bd3de2b02bd14a3fa2f49c14a5ca50a43e) [#​7976](https://redirect.github.com/npm/cli/pull/7976) `agent-base@7.1.3` - [`52bcaf6`](https://redirect.github.com/npm/cli/commit/52bcaf6464f44b30137ee3d3fe79322c1b1646ef) [#​7976](https://redirect.github.com/npm/cli/pull/7976) `debug@4.4.0` - [`aabf345`](https://redirect.github.com/npm/cli/commit/aabf345a524f8aba7e0f45c0d4b8c86d5160d0cc) [#​7976](https://redirect.github.com/npm/cli/pull/7976) `p-map@7.0.3` - [`28e8761`](https://redirect.github.com/npm/cli/commit/28e876135411cd9a93dbdd74906869c54286d7bc) [#​7976](https://redirect.github.com/npm/cli/pull/7976) `npm-package-arg@12.0.1` ##### Chores - [`ecd7190`](https://redirect.github.com/npm/cli/commit/ecd719026860d464557223b212acec4347477128) [#​7976](https://redirect.github.com/npm/cli/pull/7976) dev dependency updates ([@​wraithgar](https://redirect.github.com/wraithgar)) - [`a07f4e0`](https://redirect.github.com/npm/cli/commit/a07f4e0d921f640be6aa87736debd550ec478f89) [#​7976](https://redirect.github.com/npm/cli/pull/7976) `@npmcli/template-oss@4.23.6` ([@​wraithgar](https://redirect.github.com/wraithgar)) - [`687ab12`](https://redirect.github.com/npm/cli/commit/687ab12eb5ea0ee1017101f3a83d42fd76299627) [#​7970](https://redirect.github.com/npm/cli/pull/7970) remove pre-release mode from npm 11 and workspaces ([#​7970](https://redirect.github.com/npm/cli/issues/7970)) ([@​wraithgar](https://redirect.github.com/wraithgar)) - [workspace](https://redirect.github.com/npm/cli/releases/tag/arborist-v9.0.0): `@npmcli/arborist@9.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/config-v10.0.0): `@npmcli/config@10.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmaccess-v10.0.0): `libnpmaccess@10.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmdiff-v8.0.0): `libnpmdiff@8.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmexec-v10.0.0): `libnpmexec@10.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmfund-v7.0.0): `libnpmfund@7.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmorg-v8.0.0): `libnpmorg@8.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmpack-v9.0.0): `libnpmpack@9.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmpublish-v11.0.0): `libnpmpublish@11.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmsearch-v9.0.0): `libnpmsearch@9.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmteam-v8.0.0): `libnpmteam@8.0.0` - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmversion-v8.0.0): `libnpmversion@8.0.0` ### [`v10.9.2`](https://redirect.github.com/npm/cli/releases/tag/v10.9.2) [Compare Source](https://redirect.github.com/npm/cli/compare/v10.9.1...v10.9.2) ##### Dependencies - [`ae9345e`](https://redirect.github.com/npm/cli/commit/ae9345e352a30151c5326889d7a23b7289894e99) [#​7959](https://redirect.github.com/npm/cli/pull/7959) `@npmcli/run-script@9.0.2` - [`39a19b3`](https://redirect.github.com/npm/cli/commit/39a19b3e03991ea255d0242af176ef710dd2a1d4) [#​7959](https://redirect.github.com/npm/cli/pull/7959) `node-gyp@11.0.0` - [`93e2186`](https://redirect.github.com/npm/cli/commit/93e2186c321c3c43031af9cb3b3fb9ff97ee04eb) [#​7956](https://redirect.github.com/npm/cli/pull/7956) `@npmcli/map-workspaces@4.0.2` - [`bf0ea00`](https://redirect.github.com/npm/cli/commit/bf0ea0064931dc84c1ff92f85b78500cb586b593) [#​7956](https://redirect.github.com/npm/cli/pull/7956) `@npmcli/package-json@6.1.0` - [`c84baa3`](https://redirect.github.com/npm/cli/commit/c84baa37c65e8bf1f730c585361d4a0bf8c9c67d) [#​7956](https://redirect.github.com/npm/cli/pull/7956) `init-package-json@7.0.2` - [`e642099`](https://redirect.github.com/npm/cli/commit/e642099003f92c7247833f513782654c7f9f77c4) [#​7956](https://redirect.github.com/npm/cli/pull/7956) `node-gyp@10.3.1` ### [`v10.9.1`](https://redirect.github.com/npm/cli/releases/tag/v10.9.1) [Compare Source](https://redirect.github.com/npm/cli/compare/v10.9.0...v10.9.1) ##### Bug Fixes - [`c7fe0db`](https://redirect.github.com/npm/cli/commit/c7fe0db8c99233f8709c5e7a90578db6828353fd) [#​7924](https://redirect.github.com/npm/cli/pull/7924) perf: enable compile cache if present ([#​7924](https://redirect.github.com/npm/cli/issues/7924)) ([@​H4ad](https://redirect.github.com/H4ad)) ##### Dependencies - [`a221db7`](https://redirect.github.com/npm/cli/commit/a221db75b60d801b66089e915ed648c71d7799fd) [#​7931](https://redirect.github.com/npm/cli/pull/7931) `npm-install-checks@7.1.1` - [`fbad17a`](https://redirect.github.com/npm/cli/commit/fbad17aa990d78642836bd97faed71d3e0ca8eab) [#​7931](https://redirect.github.com/npm/cli/pull/7931) `hosted-git-info@8.0.2` - [`65d2a86`](https://redirect.github.com/npm/cli/commit/65d2a86a372d9e814f56f1c0d9ce8d73ca392696) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `@sigstore/tuf@3.0.0` - [`be45963`](https://redirect.github.com/npm/cli/commit/be45963ecf9f7c3dac85dc19696b82646c28a18e) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `sigstore@3.0.0` - [`fb0bfbd`](https://redirect.github.com/npm/cli/commit/fb0bfbd2647cf07081582b462fba869f39018c36) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `spdx-license-ids@3.0.20` - [`ccadf2a`](https://redirect.github.com/npm/cli/commit/ccadf2aa519e5de5d6b9ff62be5d2d25985b7c0f) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `promise-call-limit@3.0.2` - [`b25712e`](https://redirect.github.com/npm/cli/commit/b25712e1888d829393b9a708835ee69ec8ea1e70) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `package-json-from-dist@1.0.1` - [`1c9e96f`](https://redirect.github.com/npm/cli/commit/1c9e96fb9db044ff4cfb4bb5db9b8fc4d4073e13) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `negotiator@0.6.4` - [`f13bc9c`](https://redirect.github.com/npm/cli/commit/f13bc9c22a35fe97ba81e38d87f61c287d7ee0a9) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `debug@4.3.7` - [`029060c`](https://redirect.github.com/npm/cli/commit/029060c5e16aee4d8345635862c5fb0612f58162) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `cross-spawn@7.0.6` - [`9350950`](https://redirect.github.com/npm/cli/commit/93509509b01e8c9a1cedfb991e48e3cf0e8cbad2) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `@npmcli/metavuln-calculator@8.0.1` - [`c003827`](https://redirect.github.com/npm/cli/commit/c003827e3f5c20ec2b9a4c1631fd1dfbb5b6fb27) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `ansi-regex@6.1.0` - [`d6194f5`](https://redirect.github.com/npm/cli/commit/d6194f5774d037f474339ed47713e2809c39e735) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `pacote@19.0.1` - [`4ff29f6`](https://redirect.github.com/npm/cli/commit/4ff29f6a7ee052649d41a4084f1940d957f7959d) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `npm-registry-fetch@18.0.2` - [`fd6f4fb`](https://redirect.github.com/npm/cli/commit/fd6f4fb08f62ed5e8442528c7d532d7d064df1aa) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `make-fetch-happen@14.0.3` - [`b3f3004`](https://redirect.github.com/npm/cli/commit/b3f3004f1769d212240b40f1122d67f213073aec) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `ci-info@4.1.0` - [`a1f9d48`](https://redirect.github.com/npm/cli/commit/a1f9d482f567336d0fb802b7b67da1e71120ddd8) [#​7922](https://redirect.github.com/npm/cli/pull/7922) `promise-spawn@8.0.2` ##### Chores - [`3ace1c1`](https://redirect.github.com/npm/cli/commit/3ace1c103d7cf769acd86e1c23b39c96cec2f763) [#​7922](https://redirect.github.com/npm/cli/pull/7922) update arborist in mock-registry ([@​wraithgar](https://redirect.github.com/wraithgar)) - [workspace](https://redirect.github.com/npm/cli/releases/tag/libnpmpublish-v10.0.1): `libnpmpublish@10.0.1`
--- ### Configuration 📅 **Schedule**: Branch creation - "after 2pm on Monday" in timezone Europe/Zurich, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/adobe/spacecat-shared). --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Dominique Jäggi --- package-lock.json | 36 +++++++++---------- package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../spacecat-shared-data-access/package.json | 2 +- packages/spacecat-shared-dynamo/package.json | 2 +- packages/spacecat-shared-example/package.json | 2 +- .../package.json | 2 +- .../spacecat-shared-gpt-client/package.json | 2 +- .../spacecat-shared-http-utils/package.json | 2 +- .../spacecat-shared-ims-client/package.json | 2 +- .../package.json | 2 +- .../spacecat-shared-slack-client/package.json | 2 +- packages/spacecat-shared-utils/package.json | 2 +- 14 files changed, 31 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9e71165..9f9ad909 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "node_modules/@adobe/eslint-config-helix": { @@ -20946,7 +20946,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-ahrefs-client/node_modules/@adobe/spacecat-shared-utils": { @@ -21795,7 +21795,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-content-client/node_modules/@adobe/fetch": { @@ -23463,7 +23463,7 @@ }, "packages/spacecat-shared-data-access": { "name": "@adobe/spacecat-shared-data-access", - "version": "1.59.2", + "version": "1.60.1", "license": "Apache-2.0", "dependencies": { "@adobe/spacecat-shared-dynamo": "1.4.0", @@ -23486,7 +23486,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-data-access/node_modules/@adobe/fetch": { @@ -23813,7 +23813,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-dynamo/node_modules/@adobe/fetch": { @@ -24653,7 +24653,7 @@ "devDependencies": {}, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-google-client": { @@ -24680,7 +24680,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-google-client/node_modules/@adobe/spacecat-shared-data-access": { @@ -25825,7 +25825,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-gpt-client/node_modules/@adobe/spacecat-shared-ims-client": { @@ -26710,7 +26710,7 @@ }, "packages/spacecat-shared-http-utils": { "name": "@adobe/spacecat-shared-http-utils", - "version": "1.7.3", + "version": "1.8.0", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.1.11", @@ -26726,7 +26726,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-http-utils/node_modules/@adobe/spacecat-shared-data-access": { @@ -28408,7 +28408,7 @@ }, "packages/spacecat-shared-ims-client": { "name": "@adobe/spacecat-shared-ims-client", - "version": "1.3.27", + "version": "1.4.0", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.1.11", @@ -28425,7 +28425,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-ims-client/node_modules/@adobe/spacecat-shared-utils": { @@ -29254,7 +29254,7 @@ }, "packages/spacecat-shared-rum-api-client": { "name": "@adobe/spacecat-shared-rum-api-client", - "version": "2.15.6", + "version": "2.15.7", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.1.11", @@ -29275,7 +29275,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-rum-api-client/node_modules/@adobe/spacecat-shared-utils": { @@ -30104,7 +30104,7 @@ }, "packages/spacecat-shared-slack-client": { "name": "@adobe/spacecat-shared-slack-client", - "version": "1.3.28", + "version": "1.4.0", "license": "Apache-2.0", "dependencies": { "@adobe/helix-universal": "5.0.8", @@ -30122,7 +30122,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-slack-client/node_modules/@adobe/fetch": { @@ -30972,7 +30972,7 @@ }, "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" } }, "packages/spacecat-shared-utils/node_modules/@adobe/spacecat-shared-data-access": { diff --git a/package.json b/package.json index f0b77e2d..910c76ed 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "scripts": { "docs:api": "jsdoc2md -c .jsdoc.json --files packages/*/src/*.js > docs/API.md", diff --git a/packages/spacecat-shared-ahrefs-client/package.json b/packages/spacecat-shared-ahrefs-client/package.json index 06d35b19..49543004 100644 --- a/packages/spacecat-shared-ahrefs-client/package.json +++ b/packages/spacecat-shared-ahrefs-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-content-client/package.json b/packages/spacecat-shared-content-client/package.json index 15aaf37e..cdb2ee14 100644 --- a/packages/spacecat-shared-content-client/package.json +++ b/packages/spacecat-shared-content-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-data-access/package.json b/packages/spacecat-shared-data-access/package.json index c1f0cc5e..72c51e87 100644 --- a/packages/spacecat-shared-data-access/package.json +++ b/packages/spacecat-shared-data-access/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-dynamo/package.json b/packages/spacecat-shared-dynamo/package.json index 5d0409ab..ab3dcd4d 100644 --- a/packages/spacecat-shared-dynamo/package.json +++ b/packages/spacecat-shared-dynamo/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-example/package.json b/packages/spacecat-shared-example/package.json index f9ed8f4d..84286e46 100644 --- a/packages/spacecat-shared-example/package.json +++ b/packages/spacecat-shared-example/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/example-wrapper.js", "types": "src/example-wrapper.d.ts", diff --git a/packages/spacecat-shared-google-client/package.json b/packages/spacecat-shared-google-client/package.json index 025154cb..0087047a 100644 --- a/packages/spacecat-shared-google-client/package.json +++ b/packages/spacecat-shared-google-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-gpt-client/package.json b/packages/spacecat-shared-gpt-client/package.json index f4b84caa..bebea29f 100644 --- a/packages/spacecat-shared-gpt-client/package.json +++ b/packages/spacecat-shared-gpt-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-http-utils/package.json b/packages/spacecat-shared-http-utils/package.json index 57a976ab..9f6ce6b1 100644 --- a/packages/spacecat-shared-http-utils/package.json +++ b/packages/spacecat-shared-http-utils/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-ims-client/package.json b/packages/spacecat-shared-ims-client/package.json index a40d5a7d..e3e1f331 100644 --- a/packages/spacecat-shared-ims-client/package.json +++ b/packages/spacecat-shared-ims-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-rum-api-client/package.json b/packages/spacecat-shared-rum-api-client/package.json index 93fc8c8d..05bfd7b7 100644 --- a/packages/spacecat-shared-rum-api-client/package.json +++ b/packages/spacecat-shared-rum-api-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-slack-client/package.json b/packages/spacecat-shared-slack-client/package.json index e5345b4a..d6bb9919 100644 --- a/packages/spacecat-shared-slack-client/package.json +++ b/packages/spacecat-shared-slack-client/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts", diff --git a/packages/spacecat-shared-utils/package.json b/packages/spacecat-shared-utils/package.json index bf2aeac2..2de884fa 100644 --- a/packages/spacecat-shared-utils/package.json +++ b/packages/spacecat-shared-utils/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20.0.0 <23.0.0", - "npm": ">=10.0.0 <11.0.0" + "npm": ">=10.0.0 <12.0.0" }, "main": "src/index.js", "types": "src/index.d.ts",