diff --git a/.circleci/config.yml b/.circleci/config.yml index 99fb81f52..f8a73a67b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,8 +1,8 @@ version: 2.1 executors: - node18: + node-java: docker: - - image: cimg/node:18.18 + - image: cimg/node:18.18-browsers orbs: codecov: codecov/codecov@3.3.0 @@ -20,7 +20,7 @@ commands: jobs: build: - executor: node18 + executor: node-java parallelism: 15 steps: @@ -34,6 +34,15 @@ jobs: - run: name: Running tests and getting code coverage command: cat /tmp/tests-to-run | xargs -I % npm run test -w % + - run: + name: Running integration tests + command: | + if grep -q "packages/spacecat-shared-data-access" /tmp/tests-to-run; then + echo "Running integration tests for spacecat-shared-data-access" + npm run test:it -w packages/spacecat-shared-data-access + else + echo "Skipping integration tests" + fi - codecov/upload - run: name: Copy test results diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 3462360d2..cdc3af607 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -34,5 +34,11 @@ module.exports = { files: ['*.js', '*.cjs'], rules: {}, }, + { + files: ["*.test.js"], + rules: { + "no-unused-expressions": "off" + } + } ], }; diff --git a/docs/API.md b/docs/API.md index 6392f8287..32227de18 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,11 +1,23 @@ +## Constants + +
+
createDataAccessobject
+

Creates a data access object.

+
+
+ ## Functions
createClient(log, dbClient, docClient)Object

Creates a client object for interacting with DynamoDB.

+
isArray(value)boolean
+

Determines if the given parameter is an array.

+
isBoolean(value)boolean
-

Determines if the given value is a boolean or a string representation of a boolean.

+

Determines case-insensitively if the given value is a boolean or a string +representation of a boolean.

isInteger(value)boolean

Checks if the given value is an integer.

@@ -13,16 +25,16 @@
isNumber(value)boolean

Determines if the given value is a number.

-
isObject(obj)boolean
+
isObject(value)boolean

Checks if the given parameter is an object and not an array or null.

-
isString(str)boolean
+
isString(value)boolean

Determines if the given parameter is a string.

hasText(str)boolean

Checks if the given string is not empty.

-
isValidDate(obj)boolean
+
isValidDate(value)boolean

Checks whether the given object is a valid JavaScript Date.

isIsoDate(str)boolean
@@ -41,10 +53,22 @@ following UTC time offsets format.

Converts a given value to a boolean. Throws an error if the value is not a boolean.

arrayEquals(a, b)boolean
-

Compares two arrays for equality.

+

Compares two arrays for equality. Supports primitive array item types only.

+ + +## createDataAccess ⇒ object +Creates a data access object. + +**Kind**: global constant +**Returns**: object - data access object + +| Param | Type | Description | +| --- | --- | --- | +| log | Logger | logger | + ## createClient(log, dbClient, docClient) ⇒ Object @@ -57,12 +81,25 @@ Creates a client object for interacting with DynamoDB. | --- | --- | --- | | log | Object | The logging object, defaults to console. | | dbClient | DynamoDB | The AWS SDK DynamoDB client instance. | -| docClient | DynamoDBDocumentClient | The AWS SDK DynamoDB Document client instance. | +| docClient | DynamoDBDocument | The AWS SDK DynamoDB Document client instance. | + + + +## isArray(value) ⇒ boolean +Determines if the given parameter is an array. + +**Kind**: global function +**Returns**: boolean - True if the parameter is an array, false otherwise. + +| Param | Type | Description | +| --- | --- | --- | +| value | \* | The value to check. | ## isBoolean(value) ⇒ boolean -Determines if the given value is a boolean or a string representation of a boolean. +Determines case-insensitively if the given value is a boolean or a string +representation of a boolean. **Kind**: global function **Returns**: boolean - True if the value is a boolean or a string representation of a boolean. @@ -97,7 +134,7 @@ Determines if the given value is a number. -## isObject(obj) ⇒ boolean +## isObject(value) ⇒ boolean Checks if the given parameter is an object and not an array or null. **Kind**: global function @@ -105,11 +142,11 @@ Checks if the given parameter is an object and not an array or null. | Param | Type | Description | | --- | --- | --- | -| obj | \* | The object to check. | +| value | \* | The value to check. | -## isString(str) ⇒ boolean +## isString(value) ⇒ boolean Determines if the given parameter is a string. **Kind**: global function @@ -117,7 +154,7 @@ Determines if the given parameter is a string. | Param | Type | Description | | --- | --- | --- | -| str | \* | The string to check. | +| value | \* | The value to check. | @@ -133,7 +170,7 @@ Checks if the given string is not empty. -## isValidDate(obj) ⇒ boolean +## isValidDate(value) ⇒ boolean Checks whether the given object is a valid JavaScript Date. **Kind**: global function @@ -141,7 +178,7 @@ Checks whether the given object is a valid JavaScript Date. | Param | Type | Description | | --- | --- | --- | -| obj | \* | The object to check. | +| value | \* | The value to check. | @@ -201,7 +238,7 @@ Converts a given value to a boolean. Throws an error if the value is not a boole ## arrayEquals(a, b) ⇒ boolean -Compares two arrays for equality. +Compares two arrays for equality. Supports primitive array item types only. **Kind**: global function **Returns**: boolean - True if the arrays are equal, false otherwise. diff --git a/package-lock.json b/package-lock.json index eb9658331..7b8081e17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,10 @@ "aws4": "1.12.0" } }, + "node_modules/@adobe/spacecat-shared-data-access": { + "resolved": "packages/spacecat-shared-data-access", + "link": true + }, "node_modules/@adobe/spacecat-shared-dynamo": { "resolved": "packages/spacecat-shared-dynamo", "link": true @@ -1487,6 +1491,50 @@ "semantic-release": ">=18.0.0-beta.1" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@smithy/abort-controller": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.0.14.tgz", @@ -2808,6 +2856,18 @@ "node": ">=4" } }, + "node_modules/chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "dependencies": { + "check-error": "^1.0.2" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 5" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3587,6 +3647,15 @@ "readable-stream": "^2.0.2" } }, + "node_modules/dynamo-db-local": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/dynamo-db-local/-/dynamo-db-local-6.1.0.tgz", + "integrity": "sha512-YdF22fGHJzWoHcO9X8soOgCF1Q7AmEf/0fSEovzXCMSOZJ/Mb8nXvyMj4s75aswLJfb8Ujyd64HdFYiofC9Mkg==", + "dev": true, + "engines": { + "node": ">=16.1.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5584,6 +5653,12 @@ "node": "*" } }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5905,6 +5980,12 @@ "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", "dev": true }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", @@ -6616,6 +6697,46 @@ "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", "dev": true }, + "node_modules/nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, "node_modules/nock": { "version": "13.3.8", "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.8.tgz", @@ -9598,6 +9719,21 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-to-regexp/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10906,6 +11042,33 @@ "node": ">=4" } }, + "node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -12096,12 +12259,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/spacecat-shared-data-access": { + "name": "@adobe/spacecat-shared-data-access", + "version": "1.1.0", + "license": "Apache-2.0", + "dependencies": { + "@adobe/spacecat-shared-dynamo": "1.1.4", + "@adobe/spacecat-shared-utils": "1.2.0", + "@aws-sdk/client-dynamodb": "3.454.0", + "@aws-sdk/lib-dynamodb": "3.454.0", + "uuid": "9.0.1" + }, + "devDependencies": { + "chai": "4.3.10", + "chai-as-promised": "7.1.1", + "dynamo-db-local": "6.1.0", + "sinon": "17.0.1" + } + }, + "packages/spacecat-shared-data-access/node_modules/@adobe/spacecat-shared-dynamo": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-dynamo/-/spacecat-shared-dynamo-1.1.4.tgz", + "integrity": "sha512-geJZoXHOH3uZ5x3pFn05C1Vu5yrS2vsequYdxRLk03BjyynhLBuW0tT3vvTc9sTfFf1ZuEGhiT9hdWOQvJFh2g==", + "dependencies": { + "@adobe/spacecat-shared-utils": "1.0.1", + "@aws-sdk/client-dynamodb": "3.454.0", + "@aws-sdk/lib-dynamodb": "3.454.0" + } + }, + "packages/spacecat-shared-data-access/node_modules/@adobe/spacecat-shared-dynamo/node_modules/@adobe/spacecat-shared-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-utils/-/spacecat-shared-utils-1.0.1.tgz", + "integrity": "sha512-6HiCywan5y9jpbFYkQX87Vg0q5dXoJf2m6MiDvNBEJdREdUxFwE+oYA6Fr86qqd32ECnCeYCFO13HcNRn1RxkQ==" + }, + "packages/spacecat-shared-data-access/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "packages/spacecat-shared-dynamo": { "name": "@adobe/spacecat-shared-dynamo", - "version": "1.1.2", + "version": "1.1.3", "license": "Apache-2.0", "dependencies": { - "@adobe/spacecat-shared-utils": "1.0.1", + "@adobe/spacecat-shared-utils": "1.1.0", "@aws-sdk/client-dynamodb": "3.454.0", "@aws-sdk/lib-dynamodb": "3.454.0" }, @@ -12110,9 +12318,9 @@ } }, "packages/spacecat-shared-dynamo/node_modules/@adobe/spacecat-shared-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-utils/-/spacecat-shared-utils-1.0.1.tgz", - "integrity": "sha512-6HiCywan5y9jpbFYkQX87Vg0q5dXoJf2m6MiDvNBEJdREdUxFwE+oYA6Fr86qqd32ECnCeYCFO13HcNRn1RxkQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-utils/-/spacecat-shared-utils-1.1.0.tgz", + "integrity": "sha512-yq6o47d6IuMApgvlGWdIfUOkqYNwcEm4IzO5WHnrTpWXPK4vvssfAfFtoeki7NQ+A4nFoc7/esqOfX42Q452nA==" }, "packages/spacecat-shared-example": { "name": "@adobe/spacecat-shared-example", @@ -12128,7 +12336,7 @@ }, "packages/spacecat-shared-utils": { "name": "@adobe/spacecat-shared-utils", - "version": "1.1.0", + "version": "1.2.0", "license": "Apache-2.0", "devDependencies": { "chai": "4.3.10" diff --git a/packages/spacecat-shared-data-access/.jsdoc.json b/packages/spacecat-shared-data-access/.jsdoc.json new file mode 100644 index 000000000..405090f4b --- /dev/null +++ b/packages/spacecat-shared-data-access/.jsdoc.json @@ -0,0 +1,17 @@ +{ + "plugins": [], + "recurseDepth": 10, + "source": { + "includePattern": ".+\\.js(doc|x)?$", + "excludePattern": "(^|\\/|\\\\)_" + }, + "sourceType": "module", + "tags": { + "allowUnknownTags": true, + "dictionaries": ["jsdoc","closure"] + }, + "templates": { + "cleverLinks": false, + "monospaceLinks": false + } +} \ No newline at end of file diff --git a/packages/spacecat-shared-data-access/.mocha-multi.json b/packages/spacecat-shared-data-access/.mocha-multi.json new file mode 100644 index 000000000..aa2be2a23 --- /dev/null +++ b/packages/spacecat-shared-data-access/.mocha-multi.json @@ -0,0 +1,6 @@ +{ + "reporterEnabled": "spec,xunit", + "xunitReporterOptions": { + "output": "junit/test-results.xml" + } +} diff --git a/packages/spacecat-shared-data-access/.npmignore b/packages/spacecat-shared-data-access/.npmignore new file mode 100644 index 000000000..868317d21 --- /dev/null +++ b/packages/spacecat-shared-data-access/.npmignore @@ -0,0 +1,9 @@ +coverage/ +node_modules/ +junit/ +test/ +docs/ +logs/ +test-results.xml +renovate.json +.* diff --git a/packages/spacecat-shared-data-access/.nycrc.json b/packages/spacecat-shared-data-access/.nycrc.json new file mode 100644 index 000000000..78e7a0b14 --- /dev/null +++ b/packages/spacecat-shared-data-access/.nycrc.json @@ -0,0 +1,10 @@ +{ + "reporter": [ + "lcov", + "text" + ], + "check-coverage": true, + "lines": 100, + "branches": 97, + "statements": 100 +} diff --git a/packages/spacecat-shared-data-access/CHANGELOG.md b/packages/spacecat-shared-data-access/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/spacecat-shared-data-access/LICENSE.txt b/packages/spacecat-shared-data-access/LICENSE.txt new file mode 100644 index 000000000..883ab098f --- /dev/null +++ b/packages/spacecat-shared-data-access/LICENSE.txt @@ -0,0 +1,264 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +APACHE JACKRABBIT SUBCOMPONENTS + +Apache Jackrabbit includes parts with separate copyright notices and license +terms. Your use of these subcomponents is subject to the terms and conditions +of the following licenses: + + XPath 2.0/XQuery 1.0 Parser: + http://www.w3.org/2002/11/xquery-xpath-applets/xgrammar.zip + + Copyright (C) 2002 World Wide Web Consortium, (Massachusetts Institute of + Technology, European Research Consortium for Informatics and Mathematics, + Keio University). All Rights Reserved. + + This work is distributed under the W3C(R) Software License in the hope + that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + + W3C(R) SOFTWARE NOTICE AND LICENSE + http://www.w3.org/Consortium/Legal/2002/copyright-software-20021231 + + This work (and included software, documentation such as READMEs, or + other related items) is being provided by the copyright holders under + the following license. By obtaining, using and/or copying this work, + you (the licensee) agree that you have read, understood, and will comply + with the following terms and conditions. + + Permission to copy, modify, and distribute this software and its + documentation, with or without modification, for any purpose and + without fee or royalty is hereby granted, provided that you include + the following on ALL copies of the software and documentation or + portions thereof, including modifications: + + 1. The full text of this NOTICE in a location viewable to users + of the redistributed or derivative work. + + 2. Any pre-existing intellectual property disclaimers, notices, + or terms and conditions. If none exist, the W3C Software Short + Notice should be included (hypertext is preferred, text is + permitted) within the body of any redistributed or derivative code. + + 3. Notice of any changes or modifications to the files, including + the date changes were made. (We recommend you provide URIs to the + location from which the code is derived.) + + THIS SOFTWARE AND DOCUMENTATION IS PROVIDED "AS IS," AND COPYRIGHT + HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS + FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR + DOCUMENTATION WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, + TRADEMARKS OR OTHER RIGHTS. + + COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL + OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR + DOCUMENTATION. + + The name and trademarks of copyright holders may NOT be used in + advertising or publicity pertaining to the software without specific, + written prior permission. Title to copyright in this software and + any associated documentation will at all times remain with + copyright holders. diff --git a/packages/spacecat-shared-data-access/README.md b/packages/spacecat-shared-data-access/README.md new file mode 100644 index 000000000..65e91a262 --- /dev/null +++ b/packages/spacecat-shared-data-access/README.md @@ -0,0 +1,153 @@ +# SpaceCat Shared Data Access + +This Node.js module, `spacecat-shared-data-access`, is a data access layer for managing sites and their audits, leveraging Amazon DynamoDB. + +## Installation + +```bash +npm install @adobe/spacecat-shared-data-access +``` + +## Entities + +### Sites +- **id** (String): Unique identifier for a site. +- **baseURL** (String): Base URL of the site. +- **imsOrgId** (String): Organization ID associated with the site. +- **createdAt** (String): Timestamp of creation. +- **updatedAt** (String): Timestamp of the last update. +- **GSI1PK** (String): Partition key for the Global Secondary Index. + +### Audits +- **siteId** (String): Identifier of the site being audited. +- **SK** (String): Sort key, typically a composite of audit type and timestamp. +- **auditedAt** (String): Timestamp of the audit. +- **auditResult** (Map): Results of the audit. +- **auditType** (String): Type of the audit. +- **expiresAt** (Number): Expiry timestamp of the audit. +- **fullAuditRef** (String): Reference to the full audit details. + +## DynamoDB Data Model + +The module is designed to work with the following DynamoDB tables: + +1. **Sites Table**: Manages site records. +2. **Audits Table**: Stores audit information for each site. +3. **Latest Audits Table**: Holds only the latest audit for each site for quick access. + +Each table is designed with scalability and efficient querying in mind, utilizing both key and non-key attributes effectively. + +For a detailed schema, refer to `docs/schema.json`. This schema is importable to Amazon NoSQL Workbench and used by the integration tests. + +## Integration Testing + +The module includes comprehensive integration tests embedding a local DynamoDB server with in-memory storage for testing: + +```bash +npm run test:it +``` + +These tests create the schema, generate sample data, and test the data access patterns against the local DynamoDB instance. + +## Data Access API + +The module provides two main DAOs: + +### Site Functions +- `getSites` +- `getSitesToAudit` +- `getSitesWithLatestAudit` +- `getSiteByBaseURL` +- `getSiteByBaseURLWithAuditInfo` +- `getSiteByBaseURLWithAudits` +- `getSiteByBaseURLWithLatestAudit` +- `addSite` +- `updateSite` +- `removeSite` + +### Audit Functions +- `getAuditsForSite` +- `getAuditForSite` +- `getLatestAudits` +- `getLatestAuditForSite` +- `addAudit` + + +## Integrating Data Access in AWS Lambda Functions + +Our `spacecat-shared-data-access` module includes a wrapper that can be easily integrated into AWS Lambda functions using `@adobe/helix-shared-wrap`. This integration allows your Lambda functions to access and manipulate data seamlessly. + +### Steps for Integration + +1. **Import the Data Access Wrapper** + + Along with other wrappers and utilities, import the `dataAccessWrapper`. + + ```javascript + import dataAccessWrapper from '@adobe/spacecat-shared-data-access/wrapper'; + ``` + +2. **Modify Your Lambda Wrapper Script** + + Include `dataAccessWrapper` in the chain of wrappers when defining your Lambda handler. + + ```javascript + export const main = wrap(run) + .with(sqsEventAdapter) + .with(dataAccessWrapper) // Add this line + .with(sqs) + .with(secrets) + .with(helixStatus); + ``` + +3. **Access Data in Your Lambda Function** + + Use the `dataAccess` object from the context to interact with your data layer. + + ```javascript + async function run(message, context) { + const { dataAccess } = context; + + // Example: Retrieve all sites + const sites = await dataAccess.getSites(); + // ... more logic ... + } + ``` + +### Example + +Here's a complete example of a Lambda function utilizing the data access wrapper: + +```javascript +import wrap from '@adobe/helix-shared-wrap'; +import dataAccessWrapper from '@adobe/spacecat-shared-data-access/wrapper'; +import sqsEventAdapter from './sqsEventAdapter'; +import sqs from './sqs'; +import secrets from '@adobe/helix-shared-secrets'; +import helixStatus from '@adobe/helix-status'; + +async function run(message, context) { + const { dataAccess } = context; + try { + const sites = await dataAccess.getSites(); + // Function logic here + } catch (error) { + // Error handling + } +} + +export const main = wrap(run) + .with(sqsEventAdapter) + .with(dataAccessWrapper) + .with(sqs) + .with(secrets) + .with(helixStatus); +``` + +## Contributing + +Contributions to `spacecat-shared-data-access` are welcome. Please adhere to the standard Git workflow and submit pull requests for proposed changes. + +## License + +Licensed under the Apache-2.0 License. diff --git a/packages/spacecat-shared-data-access/docs/schema.json b/packages/spacecat-shared-data-access/docs/schema.json new file mode 100644 index 000000000..4ba87ec92 --- /dev/null +++ b/packages/spacecat-shared-data-access/docs/schema.json @@ -0,0 +1,290 @@ +{ + "ModelName": "StarCatalogue", + "ModelMetadata": { + "Author": "Dominique Jäggi", + "DateCreated": "Nov 23, 2023, 07:00 AM", + "DateLastModified": "Nov 23, 2023, 07:00 AM", + "Description": "", + "AWSService": "Amazon DynamoDB", + "Version": "3.0" + }, + "DataModel": [ + { + "TableName": "sites", + "KeyAttributes": { + "PartitionKey": { + "AttributeName": "id", + "AttributeType": "S" + } + }, + "NonKeyAttributes": [ + { + "AttributeName": "baseURL", + "AttributeType": "S" + }, + { + "AttributeName": "imsOrgId", + "AttributeType": "S" + }, + { + "AttributeName": "GSI1PK", + "AttributeType": "S" + }, + { + "AttributeName": "createdAt", + "AttributeType": "S" + }, + { + "AttributeName": "updatedAt", + "AttributeType": "S" + } + ], + "GlobalSecondaryIndexes": [ + { + "IndexName": "all_sites", + "KeyAttributes": { + "PartitionKey": { + "AttributeName": "GSI1PK", + "AttributeType": "S" + }, + "SortKey": { + "AttributeName": "baseURL", + "AttributeType": "S" + } + }, + "Projection": { + "ProjectionType": "ALL" + } + } + ], + "DataAccess": { + "MySql": {} + }, + "SampleDataFormats": { + "id": [ + "identifiers", + "UUID" + ], + "baseURL": [ + "identifiers", + "URL" + ], + "imsOrgId": [ + "identifiers", + "UUID" + ] + }, + "BillingMode": "PROVISIONED", + "ProvisionedCapacitySettings": { + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "AutoScalingRead": { + "ScalableTargetRequest": { + "MinCapacity": 1, + "MaxCapacity": 10, + "ServiceRole": "AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" + }, + "ScalingPolicyConfiguration": { + "TargetValue": 70 + } + }, + "AutoScalingWrite": { + "ScalableTargetRequest": { + "MinCapacity": 1, + "MaxCapacity": 10, + "ServiceRole": "AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" + }, + "ScalingPolicyConfiguration": { + "TargetValue": 70 + } + } + } + }, + { + "TableName": "audits", + "KeyAttributes": { + "PartitionKey": { + "AttributeName": "siteId", + "AttributeType": "S" + }, + "SortKey": { + "AttributeName": "SK", + "AttributeType": "S" + } + }, + "NonKeyAttributes": [ + { + "AttributeName": "auditedAt", + "AttributeType": "S" + }, + { + "AttributeName": "auditResult", + "AttributeType": "M" + }, + { + "AttributeName": "auditType", + "AttributeType": "S" + }, + { + "AttributeName": "expiresAt", + "AttributeType": "N" + }, + { + "AttributeName": "fullAuditRef", + "AttributeType": "S" + } + ], + "DataAccess": { + "MySql": {} + }, + "SampleDataFormats": { + "siteId": [ + "identifiers", + "UUID" + ], + "auditedAt": [ + "date", + "ISO 8601 date and time" + ], + "fullAuditRef": [ + "identifiers", + "URL" + ] + }, + "BillingMode": "PROVISIONED", + "ProvisionedCapacitySettings": { + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "AutoScalingRead": { + "ScalableTargetRequest": { + "MinCapacity": 1, + "MaxCapacity": 10, + "ServiceRole": "AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" + }, + "ScalingPolicyConfiguration": { + "TargetValue": 70 + } + }, + "AutoScalingWrite": { + "ScalableTargetRequest": { + "MinCapacity": 1, + "MaxCapacity": 10, + "ServiceRole": "AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" + }, + "ScalingPolicyConfiguration": { + "TargetValue": 70 + } + } + } + }, + { + "TableName": "latest_audits", + "KeyAttributes": { + "PartitionKey": { + "AttributeName": "siteId", + "AttributeType": "S" + }, + "SortKey": { + "AttributeName": "SK", + "AttributeType": "S" + } + }, + "NonKeyAttributes": [ + { + "AttributeName": "auditedAt", + "AttributeType": "S" + }, + { + "AttributeName": "auditResult", + "AttributeType": "M" + }, + { + "AttributeName": "auditType", + "AttributeType": "S" + }, + { + "AttributeName": "expiresAt", + "AttributeType": "N" + }, + { + "AttributeName": "fullAuditRef", + "AttributeType": "S" + }, + { + "AttributeName": "GSI1PK", + "AttributeType": "S" + }, + { + "AttributeName": "GSI1SK", + "AttributeType": "S" + } + ], + "GlobalSecondaryIndexes": [ + { + "IndexName": "all_latest_audit_scores", + "KeyAttributes": { + "PartitionKey": { + "AttributeName": "GSI1PK", + "AttributeType": "S" + }, + "SortKey": { + "AttributeName": "GSI1SK", + "AttributeType": "S" + } + }, + "Projection": { + "ProjectionType": "ALL" + } + } + ], + "DataAccess": { + "MySql": {} + }, + "SampleDataFormats": { + "siteId": [ + "identifiers", + "UUID" + ], + "auditedAt": [ + "date", + "ISO 8601 date and time" + ], + "fullAuditRef": [ + "identifiers", + "URL" + ] + }, + "BillingMode": "PROVISIONED", + "ProvisionedCapacitySettings": { + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "AutoScalingRead": { + "ScalableTargetRequest": { + "MinCapacity": 1, + "MaxCapacity": 10, + "ServiceRole": "AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" + }, + "ScalingPolicyConfiguration": { + "TargetValue": 70 + } + }, + "AutoScalingWrite": { + "ScalableTargetRequest": { + "MinCapacity": 1, + "MaxCapacity": 10, + "ServiceRole": "AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" + }, + "ScalingPolicyConfiguration": { + "TargetValue": 70 + } + } + } + } + ] +} diff --git a/packages/spacecat-shared-data-access/package.json b/packages/spacecat-shared-data-access/package.json new file mode 100644 index 000000000..327071b69 --- /dev/null +++ b/packages/spacecat-shared-data-access/package.json @@ -0,0 +1,44 @@ +{ + "name": "@adobe/spacecat-shared-data-access", + "version": "1.1.0", + "description": "Shared modules of the Spacecat Services - Data Access", + "type": "module", + "main": "src/service/index.js", + "types": "src/index.d.ts", + "scripts": { + "test:it": "mocha --spec \"test/it/**/*.test.js\"", + "test": "c8 mocha --spec \"test/unit/**/*.test.js\"", + "lint": "eslint .", + "clean": "rm -rf package-lock.json node_modules" + }, + "mocha": { + "reporter": "mocha-multi-reporters", + "reporter-options": "configFile=.mocha-multi.json" + }, + "repository": { + "type": "git", + "url": "https://github.com/adobe-rnd/spacecat-shared.git" + }, + "author": "", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/adobe-rnd/spacecat-shared/issues" + }, + "homepage": "https://github.com/adobe-rnd/spacecat-shared#readme", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@adobe/spacecat-shared-dynamo": "1.1.4", + "@adobe/spacecat-shared-utils": "1.2.0", + "@aws-sdk/client-dynamodb": "3.454.0", + "@aws-sdk/lib-dynamodb": "3.454.0", + "uuid": "9.0.1" + }, + "devDependencies": { + "chai": "4.3.10", + "chai-as-promised": "7.1.1", + "dynamo-db-local": "6.1.0", + "sinon": "17.0.1" + } +} diff --git a/packages/spacecat-shared-data-access/src/dto/audit.js b/packages/spacecat-shared-data-access/src/dto/audit.js new file mode 100644 index 000000000..0903e251c --- /dev/null +++ b/packages/spacecat-shared-data-access/src/dto/audit.js @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { createAudit } from '../models/audit.js'; + +function parseEpochToDate(epochInSeconds) { + const milliseconds = epochInSeconds * 1000; + return new Date(milliseconds); +} + +function convertDateToEpochSeconds(date) { + return Math.floor(date.getTime() / 1000); +} + +/** + * Data transfer object for Audit. + */ +export const AuditDto = { + /** + * Converts an Audit object into a DynamoDB item. + * @param {Readonly} audit - Audit object. + * @param {boolean} latestAudit - If true, returns the latest audit flavor. + * @returns {{siteId, auditedAt, auditResult, auditType, expiresAt, fullAuditRef, SK: string}} + */ + toDynamoItem: (audit, latestAudit = false) => { + const latestAuditProps = latestAudit ? { + GSI1PK: 'ALL_LATEST_AUDITS', + GSI1SK: `${audit.getAuditType()}#${Object.values(audit.getScores()).join('#')}`, + } : {}; + + return { + siteId: audit.getSiteId(), + auditedAt: audit.getAuditedAt(), + auditResult: audit.getAuditResult(), + auditType: audit.getAuditType(), + expiresAt: convertDateToEpochSeconds(audit.getExpiresAt()), + fullAuditRef: audit.getFullAuditRef(), + SK: `${audit.getAuditType()}#${audit.getAuditedAt()}`, + ...latestAuditProps, + }; + }, + + /** + * Converts a DynamoDB item into an Audit object. + * @param {object} dynamoItem - DynamoDB item. + * @returns {Readonly} Audit object. + */ + fromDynamoItem: (dynamoItem) => { + const auditData = { + siteId: dynamoItem.siteId, + auditedAt: dynamoItem.auditedAt, + auditResult: dynamoItem.auditResult, + auditType: dynamoItem.auditType, + expiresAt: parseEpochToDate(dynamoItem.expiresAt), + fullAuditRef: dynamoItem.fullAuditRef, + }; + + return createAudit(auditData); + }, +}; diff --git a/packages/spacecat-shared-data-access/src/dto/site.js b/packages/spacecat-shared-data-access/src/dto/site.js new file mode 100644 index 000000000..c2203f5dc --- /dev/null +++ b/packages/spacecat-shared-data-access/src/dto/site.js @@ -0,0 +1,49 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { createSite } from '../models/site.js'; + +/** + * Data transfer object for Site. + */ +export const SiteDto = { + /** + * Converts a Site object into a DynamoDB item. + * @param {Readonly} site - Site object. + * @returns {{createdAt, baseURL, GSI1PK: string, id, imsOrgId, updatedAt}} + */ + toDynamoItem: (site) => ({ + id: site.getId(), + baseURL: site.getBaseURL(), + imsOrgId: site.getImsOrgId(), + createdAt: site.getCreatedAt(), + updatedAt: site.getUpdatedAt(), + GSI1PK: 'ALL_SITES', + }), + + /** + * Converts a DynamoDB item into a Site object. + * @param {object } dynamoItem - DynamoDB item. + * @returns {Readonly} Site object. + */ + fromDynamoItem: (dynamoItem) => { + const siteData = { + id: dynamoItem.id, + baseURL: dynamoItem.baseURL, + imsOrgId: dynamoItem.imsOrgId, + createdAt: dynamoItem.createdAt, + updatedAt: dynamoItem.updatedAt, + }; + + return createSite(siteData); + }, +}; diff --git a/packages/spacecat-shared-data-access/src/index.d.ts b/packages/spacecat-shared-data-access/src/index.d.ts new file mode 100644 index 000000000..09a4d509d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/index.d.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// TODO: introduce AuditType interface or Scores interface + +export interface Audit { + getSiteId: () => string; + getAuditedAt: () => string; + getAuditResult: () => object; + getAuditType: () => object; + getExpiresAt: () => Date; + getFullAuditRef: () => string; + getScores: () => object; +} + +export interface Site { + getId: () => string; + getBaseURL: () => string; + getImsOrgId: () => string; + getCreatedAt: () => string; + getUpdatedAt: () => string; + getAudits: () => Audit[]; + updateImsOrgId: (imsOrgId: string) => Site; + setAudits: (audits: Audit[]) => Site; +} + +export interface DataAccess { + getAuditsForSite: ( + siteId: string, + auditType?: string + ) => Promise; + getLatestAuditForSite: ( + siteId: string, + auditType: string, + ) => Promise; + getLatestAudits: ( + auditType: string, + ascending?: boolean, + ) => Promise; + getLatestAuditsForSite: ( + siteId: string, + ) => Promise; + getSites: () => Promise; + getSitesToAudit: () => Promise; + getSitesWithLatestAudit: ( + auditType: string, + sortAuditsAscending?: boolean, + ) => Promise; + getSiteByBaseURL: ( + baseUrl: string, + ) => Promise; + getSiteByBaseURLWithAuditInfo: ( + baseUrl: string, + auditType: string, + latestOnly?: boolean, + ) => Promise; + getSiteByBaseURLWithAudits: ( + baseUrl: string, + auditType: string, + ) => Promise; + getSiteByBaseURLWithLatestAudit: ( + baseUrl: string, + auditType: string, + ) => Promise; + addSite: ( + siteData: object, + ) => Promise; + updateSite: ( + site: Site, + ) => Promise; + removeSite: ( + siteId: string, + ) => Promise; +} + +export function createDataAccess( + logger: object, +): DataAccess; diff --git a/packages/spacecat-shared-data-access/src/index.js b/packages/spacecat-shared-data-access/src/index.js new file mode 100644 index 000000000..3fab17cb4 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/index.js @@ -0,0 +1,24 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { createDataAccess } from './service/index.js'; + +export default function dataAccessWrapper(fn) { + return async (request, context) => { + if (!context.dataAccess) { + const { log } = context; + context.dataAccess = createDataAccess(log); + } + + return fn(request, context); + }; +} diff --git a/packages/spacecat-shared-data-access/src/models/audit.js b/packages/spacecat-shared-data-access/src/models/audit.js new file mode 100644 index 000000000..6b9d77f34 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/audit.js @@ -0,0 +1,103 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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, isIsoDate, isObject } from '@adobe/spacecat-shared-utils'; +import { Base } from './base.js'; + +export const AUDIT_TYPE_CWV = 'cwv'; +export const AUDIT_TYPE_LHS = 'lhs'; + +const EXPIRES_IN_DAYS = 30; + +const AUDIT_TYPE_PROPERTIES = { + [AUDIT_TYPE_CWV]: [], + [AUDIT_TYPE_LHS]: ['performance', 'seo', 'accessibility', 'best-practices'], +}; + +/** + * 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. + */ +const validateAuditResult = (auditResult, auditType) => { + const expectedProperties = AUDIT_TYPE_PROPERTIES[auditType]; + if (!expectedProperties) { + throw new Error(`Unknown audit type: ${auditType}`); + } + + for (const prop of expectedProperties) { + if (!(prop in auditResult)) { + throw new Error(`Missing expected property '${prop}' for audit type '${auditType}'`); + } + } + + return true; +}; + +/** + * Creates a new Audit. + * @param {object } data - audit data + * @returns {Readonly} audit - new audit + */ +const Audit = (data = {}) => { + const self = Base(data); + + self.getSiteId = () => self.state.siteId; + self.getAuditedAt = () => self.state.auditedAt; + self.getAuditResult = () => self.state.auditResult; + self.getAuditType = () => self.state.auditType.toLowerCase(); + self.getExpiresAt = () => self.state.expiresAt; + self.getFullAuditRef = () => self.state.fullAuditRef; + self.getScores = () => self.getAuditResult(); + + return Object.freeze(self); +}; + +/** + * Creates a new Audit. + * + * @param {object} data - audit data + * @returns {Readonly} audit - new audit + */ +export const createAudit = (data) => { + const newState = { ...data }; + + if (!hasText(newState.siteId)) { + throw new Error('Site ID must be provided'); + } + + if (!isIsoDate(newState.auditedAt)) { + throw new Error('Audited at must be a valid ISO date'); + } + + if (!hasText(newState.auditType)) { + throw new Error('Audit type must be provided'); + } + + if (!isObject(newState.auditResult)) { + throw new Error('Audit result must be an object'); + } + + validateAuditResult(data.auditResult, data.auditType); + + if (!hasText(newState.fullAuditRef)) { + throw new Error('Full audit ref must be provided'); + } + + if (!newState.expiresAt) { + newState.expiresAt = new Date(newState.auditedAt); + newState.expiresAt.setDate(newState.expiresAt.getDate() + EXPIRES_IN_DAYS); + } + + return Audit(newState); +}; diff --git a/packages/spacecat-shared-data-access/src/models/base.js b/packages/spacecat-shared-data-access/src/models/base.js new file mode 100644 index 000000000..fa4787cd6 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/base.js @@ -0,0 +1,42 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { v4 as uuidv4 } from 'uuid'; +import { isString } from '@adobe/spacecat-shared-utils'; + +/** + * Base model. + * + * @param {object} data data + * @returns {Base} base model + */ +export const Base = (data = {}) => { + const self = { state: { ...data } }; + const newRecord = !isString(self.state.id); + const nowISO = new Date().toISOString(); + + if (newRecord) { + self.state.id = uuidv4(); + self.state.createdAt = nowISO; + self.state.updatedAt = nowISO; + } + + self.getId = () => self.state.id; + self.getCreatedAt = () => self.state.createdAt; + self.getUpdatedAt = () => self.state.updatedAt; + + self.touch = () => { + self.state.updatedAt = new Date().toISOString(); + }; + + return self; +}; diff --git a/packages/spacecat-shared-data-access/src/models/site.js b/packages/spacecat-shared-data-access/src/models/site.js new file mode 100644 index 000000000..94fe8cc35 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/site.js @@ -0,0 +1,103 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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, isValidUrl } from '@adobe/spacecat-shared-utils'; +import { Base } from './base.js'; + +/** + * Creates a new Site. + * + * @param {object} data - site data + * @returns {Readonly} site - new site + */ +const Site = (data = {}) => { + const self = Base(data); + + self.getAudits = () => self.state.audits; + self.getBaseURL = () => self.state.baseURL; + self.getImsOrgId = () => self.state.imsOrgId; + + // TODO: updating the baseURL is not supported yet, it will require a transact write + // on dynamodb (put then delete) since baseURL is part of the primary key, something like: + // const updateSiteBaseURL = async (oldBaseURL, updatedSiteData) => { + // const params = { + // TransactItems: [ + // { + // Put: { + // TableName: 'YourSiteTableName', + // Item: updatedSiteData, + // }, + // }, + // { + // Delete: { + // TableName: 'YourSiteTableName', + // Key: { + // baseURL: oldBaseURL, + // }, + // }, + // }, + // ], + // }; + // + // await dynamoDbClient.transactWrite(params).promise(); + // + // return createSite(updatedSiteData); + // }; + /* self.updateBaseURL = (baseURL) => { + if (!isValidUrl(baseURL)) { + throw new Error('Base URL must be a valid URL'); + } + + self.state.baseURL = baseURL; + self.touch(); + + return self; + }; */ + + self.updateImsOrgId = (imsOrgId) => { + if (!hasText(imsOrgId)) { + throw new Error('IMS Org ID must be provided'); + } + + self.state.imsOrgId = imsOrgId; + self.touch(); + + return self; + }; + + self.setAudits = (audits) => { + self.state.audits = audits; + return self; + }; + + return Object.freeze(self); +}; + +/** + * Creates a new Site. + * + * @param {object} data - site data + * @returns {Readonly} site - new site + */ +export const createSite = (data) => { + const newState = { ...data }; + + if (!isValidUrl(newState.baseURL)) { + throw new Error('Base URL must be a valid URL'); + } + + if (!Array.isArray(newState.audits)) { + newState.audits = []; + } + + return Site(newState); +}; diff --git a/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js new file mode 100644 index 000000000..df611922d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/service/audits/accessPatterns.js @@ -0,0 +1,231 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { AuditDto } from '../../dto/audit.js'; +import { createAudit } from '../../models/audit.js'; + +const TABLE_NAME_AUDITS = 'audits'; +const TABLE_NAME_LATEST_AUDITS = 'latest_audits'; +const INDEX_NAME_ALL_LATEST_AUDIT_SCORES = 'all_latest_audit_scores'; +const PK_ALL_LATEST_AUDITS = 'ALL_LATEST_AUDITS'; + +/** + * Retrieves audits for a specified site. If an audit type is provided, + * it returns only audits of that type. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @param {string} siteId - The ID of the site for which audits are being retrieved. + * @param {string} [auditType] - Optional. The type of audits to retrieve. + * @returns {Promise[]>} A promise that resolves to an array of audits + * for the specified site. + */ +export const getAuditsForSite = async (dynamoClient, log, siteId, auditType) => { + const queryParams = { + TableName: TABLE_NAME_AUDITS, + KeyConditionExpression: 'siteId = :siteId', + ExpressionAttributeValues: { + ':siteId': siteId, + }, + }; + + if (auditType !== undefined) { + queryParams.KeyConditionExpression += ' AND begins_with(SK, :auditType)'; + queryParams.ExpressionAttributeValues[':auditType'] = `${auditType}#`; + } + + const dynamoItems = await dynamoClient.query(queryParams); + + return dynamoItems.map((item) => AuditDto.fromDynamoItem(item)); +}; + +/** + * Retrieves a specific audit for a specified site. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @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. + * @returns {Promise|null>} + */ +export const getAuditForSite = async ( + dynamoClient, + log, + siteId, + auditType, + auditedAt, +) => { + const audit = await dynamoClient.query({ + TableName: TABLE_NAME_AUDITS, + KeyConditionExpression: 'siteId = :siteId AND SK = :sk', + ExpressionAttributeValues: { + ':siteId': siteId, + ':sk': `${auditType}#${auditedAt}`, + }, + Limit: 1, + }); + + return audit.length > 0 ? AuditDto.fromDynamoItem(audit[0]) : null; +}; + +/** + * Retrieves the latest audits of a specific type across all sites. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @param {string} auditType - The type of audits to retrieve. + * @param {boolean} ascending - Determines if the audits should be sorted ascending + * or descending by scores. + * @returns {Promise[]>} A promise that resolves to an array of the latest + * audits of the specified type. + */ +export const getLatestAudits = async ( + dynamoClient, + log, + auditType, + ascending = true, +) => { + const dynamoItems = await dynamoClient.query({ + TableName: TABLE_NAME_LATEST_AUDITS, + IndexName: INDEX_NAME_ALL_LATEST_AUDIT_SCORES, + KeyConditionExpression: 'GSI1PK = :gsi1pk AND begins_with(GSI1SK, :auditType)', + ExpressionAttributeValues: { + ':gsi1pk': PK_ALL_LATEST_AUDITS, + ':auditType': `${auditType}#`, + }, + ScanIndexForward: ascending, // Sorts ascending if true, descending if false + }); + + return dynamoItems.map((item) => AuditDto.fromDynamoItem(item)); +}; + +/** + * Retrieves latest audits for a specified site. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @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. + */ +export const getLatestAuditsForSite = async (dynamoClient, log, siteId) => { + const queryParams = { + TableName: TABLE_NAME_LATEST_AUDITS, + KeyConditionExpression: 'siteId = :siteId', + ExpressionAttributeValues: { ':siteId': siteId }, + }; + + const dynamoItems = await dynamoClient.query(queryParams); + + return dynamoItems.map((item) => AuditDto.fromDynamoItem(item)); +}; + +/** + * Retrieves the latest audit for a specified site and audit type. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @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 + * specified type for the site, or null if none is found. + */ +export const getLatestAuditForSite = async ( + dynamoClient, + log, + siteId, + auditType, +) => { + const latestAudit = await dynamoClient.query({ + TableName: TABLE_NAME_LATEST_AUDITS, + KeyConditionExpression: 'siteId = :siteId AND begins_with(SK, :auditType)', + ExpressionAttributeValues: { + ':siteId': siteId, + ':auditType': `${auditType}#`, + }, + Limit: 1, + }); + + return latestAudit.length > 0 ? AuditDto.fromDynamoItem(latestAudit[0]) : null; +}; + +/** + * Adds an audit. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @param {object} auditData - The audit data. + * @returns {Promise>} + */ +export const addAudit = async (dynamoClient, log, auditData) => { + const audit = createAudit(auditData); + const existingAudit = await getAuditForSite( + dynamoClient, + log, + audit.getSiteId(), + audit.getAuditType(), + audit.getAuditedAt(), + ); + + if (isObject(existingAudit)) { + throw new Error('Audit already exists'); + } + + // TODO: Add transaction support + await dynamoClient.putItem('audits', AuditDto.toDynamoItem(audit)); + await dynamoClient.putItem('latest_audits', AuditDto.toDynamoItem(audit, true)); + + return audit; +}; + +/** + * Removes audits from the database. + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @param audits + * @param latest + * @returns {Promise} + */ +async function removeAudits(dynamoClient, audits, latest = false) { + const tableName = latest ? TABLE_NAME_LATEST_AUDITS : TABLE_NAME_AUDITS; + // TODO: use batch-remove (needs dynamo client update) + const removeAuditPromises = audits.map((audit) => dynamoClient.removeItem(tableName, { + siteId: audit.getSiteId(), + SK: `${audit.getAuditType()}#${audit.getAuditedAt()}`, + })); + + await Promise.all(removeAuditPromises); +} + +/** + * Removes all audits for a specified site and the latest audit entry. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @param {string} siteId - The ID of the site for which audits are being removed. + * @returns {Promise} + */ +export const removeAuditsForSite = async (dynamoClient, log, siteId) => { + try { + const audits = await getAuditsForSite(dynamoClient, log, siteId); + const latestAudits = await getLatestAuditsForSite(dynamoClient, log, siteId); + + await removeAudits(dynamoClient, audits); + await removeAudits(dynamoClient, latestAudits, true); + } catch (error) { + log.error(`Error removing audits for site ${siteId}: ${error.message}`); + throw error; + } +}; diff --git a/packages/spacecat-shared-data-access/src/service/audits/index.js b/packages/spacecat-shared-data-access/src/service/audits/index.js new file mode 100644 index 000000000..f21e86b27 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/service/audits/index.js @@ -0,0 +1,57 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { + addAudit, getAuditForSite, + getAuditsForSite, + getLatestAuditForSite, + getLatestAudits, + removeAuditsForSite, +} from './accessPatterns.js'; + +export const auditFunctions = (dynamoClient, log) => ({ + getAuditForSite: (siteId, auditType, auditedAt) => getAuditForSite( + dynamoClient, + log, + siteId, + auditType, + auditedAt, + ), + getAuditsForSite: (siteId, auditType) => getAuditsForSite( + dynamoClient, + log, + siteId, + auditType, + ), + getLatestAudits: (auditType, ascending) => getLatestAudits( + dynamoClient, + log, + auditType, + ascending, + ), + getLatestAuditForSite: (siteId, auditType) => getLatestAuditForSite( + dynamoClient, + log, + siteId, + auditType, + ), + addAudit: (auditData) => addAudit( + dynamoClient, + log, + auditData, + ), + removeAuditsForSite: (siteId) => removeAuditsForSite( + dynamoClient, + log, + siteId, + ), +}); diff --git a/packages/spacecat-shared-data-access/src/service/index.js b/packages/spacecat-shared-data-access/src/service/index.js new file mode 100644 index 000000000..6f0b9bddf --- /dev/null +++ b/packages/spacecat-shared-data-access/src/service/index.js @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { createClient } from '@adobe/spacecat-shared-dynamo'; +import { auditFunctions } from './audits/index.js'; +import { siteFunctions } from './sites/index.js'; + +/** + * Creates a data access object. + * + * @param {Logger} log logger + * @returns {object} data access object + */ +export const createDataAccess = (log = console) => { + const dynamoClient = createClient(log); + + const auditFuncs = auditFunctions(dynamoClient, log); + const siteFuncs = siteFunctions(dynamoClient, log); + + return { + ...auditFuncs, + ...siteFuncs, + }; +}; diff --git a/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js b/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js new file mode 100644 index 000000000..f5bcf99c6 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/service/sites/accessPatterns.js @@ -0,0 +1,265 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { + getAuditsForSite, + getLatestAuditForSite, + getLatestAudits, removeAuditsForSite, +} from '../audits/accessPatterns.js'; + +import { createSite } from '../../models/site.js'; +import { SiteDto } from '../../dto/site.js'; + +const INDEX_NAME_ALL_SITES = 'all_sites'; +const PK_ALL_SITES = 'ALL_SITES'; +const TABLE_NAME_SITES = 'sites'; + +/** + * Retrieves all sites. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @returns {Promise[]>} A promise that resolves to an array of all sites. + */ +export const getSites = async (dynamoClient) => { + const dynamoItems = await dynamoClient.query({ + TableName: TABLE_NAME_SITES, + IndexName: INDEX_NAME_ALL_SITES, // GSI name + KeyConditionExpression: 'GSI1PK = :gsi1pk', + ExpressionAttributeValues: { + ':gsi1pk': PK_ALL_SITES, + }, + }); + + return dynamoItems.map((dynamoItem) => SiteDto.fromDynamoItem(dynamoItem)); +}; + +/** + * Retrieves a list of base URLs for all sites. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @returns {Promise>} A promise that resolves to an array of base URLs for all sites. + */ +export const getSitesToAudit = async (dynamoClient) => { + const sites = await getSites(dynamoClient); + + return sites.map((site) => site.getBaseURL()); +}; + +/** + * Retrieves sites with their latest audit of a specified type. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @param {string} auditType - The type of the latest audits to retrieve for each site. + * @param {boolean} [sortAuditsAscending] - Optional. Determines if the audits + * should be sorted ascending or descending by scores. + * @returns {Promise[]>} A promise that resolves to an array of site objects, + * each with its latest audit of the specified type. + */ +export const getSitesWithLatestAudit = async ( + dynamoClient, + log, + auditType, + sortAuditsAscending = true, +) => { + const [sites, latestAudits] = await Promise.all([ + getSites(dynamoClient), + getLatestAudits(dynamoClient, log, auditType, sortAuditsAscending), + ]); + + const sitesMap = new Map(sites.map((site) => [site.getId(), site])); + + return latestAudits.reduce((result, audit) => { + const site = sitesMap.get(audit.getSiteId()); + if (site) { + site.setAudits([audit]); + result.push(site); + } + return result; + }, []); +}; + +/** + * Retrieves a site by its base URL. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @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. + */ +export const getSiteByBaseURL = async ( + dynamoClient, + log, + baseURL, +) => { + const dynamoItems = await dynamoClient.query({ + TableName: TABLE_NAME_SITES, + IndexName: INDEX_NAME_ALL_SITES, + KeyConditionExpression: 'GSI1PK = :gsi1pk AND baseURL = :baseURL', + ExpressionAttributeValues: { + ':gsi1pk': PK_ALL_SITES, + ':baseURL': baseURL, + }, + Limit: 1, + }); + + if (dynamoItems.length === 0) { + return null; + } + + return SiteDto.fromDynamoItem(dynamoItems[0]); +}; + +/** + * Retrieves a site by its base URL, along with associated audit information. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @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. + * @returns {Promise|null>} A promise that resolves to the site object with audit + * data if found, otherwise null. + */ +export const getSiteByBaseURLWithAuditInfo = async ( + dynamoClient, + log, + baseUrl, + auditType, + latestOnly = false, +) => { + const site = await getSiteByBaseURL(dynamoClient, log, baseUrl); + + if (!isObject(site)) { + return null; + } + + const audits = latestOnly + ? [await getLatestAuditForSite( + dynamoClient, + log, + site.getId(), + auditType, + )].filter((audit) => audit != null) + : await getAuditsForSite( + dynamoClient, + log, + site.getId(), + auditType, + ); + + site.setAudits(audits); + + return site; +}; + +/** + * Retrieves a site by its base URL, including all its audits. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @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 + * with all its audits. + */ +export const getSiteByBaseURLWithAudits = async ( + dynamoClient, + log, + baseUrl, + auditType, +) => getSiteByBaseURLWithAuditInfo(dynamoClient, log, baseUrl, auditType, false); + +/** + * Retrieves a site by its base URL, including only its latest audit. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @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 + * with its latest audit. + */ +export const getSiteByBaseURLWithLatestAudit = async ( + dynamoClient, + log, + baseUrl, + auditType, +) => getSiteByBaseURLWithAuditInfo(dynamoClient, log, baseUrl, auditType, true); + +/** + * Adds a site. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @param {object} siteData - The site data. + * @returns {Promise>} + */ +export const addSite = async (dynamoClient, log, siteData) => { + const site = createSite(siteData); + const existingSite = await getSiteByBaseURL( + dynamoClient, + log, + site.getBaseURL(), + ); + + if (isObject(existingSite)) { + throw new Error('Site already exists'); + } + + await dynamoClient.putItem(TABLE_NAME_SITES, SiteDto.toDynamoItem(site)); + + return site; +}; + +/** + * Updates a site. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @param {Site} site - The site. + * @returns {Promise>} - The updated site. + */ +export const updateSite = async (dynamoClient, log, site) => { + const existingSite = await getSiteByBaseURL(dynamoClient, log, site.getBaseURL()); + + if (!isObject(existingSite)) { + throw new Error('Site not found'); + } + + await dynamoClient.putItem(TABLE_NAME_SITES, SiteDto.toDynamoItem(site)); + + return site; +}; + +/** + * Removes a site and its related audits. + * + * @param {DynamoDbClient} dynamoClient - The DynamoDB client. + * @param {Logger} log - The logger. + * @param {string} siteId - The ID of the site to remove. + * @returns {Promise} + */ +export const removeSite = async (dynamoClient, log, siteId) => { + try { + // TODO: Add transaction support + await removeAuditsForSite(dynamoClient, log, siteId); + + await dynamoClient.removeItem(TABLE_NAME_SITES, { id: siteId }); + } catch (error) { + log.error(`Error removing site: ${error.message}`); + throw error; + } +}; diff --git a/packages/spacecat-shared-data-access/src/service/sites/index.js b/packages/spacecat-shared-data-access/src/service/sites/index.js new file mode 100644 index 000000000..f43a2cb5c --- /dev/null +++ b/packages/spacecat-shared-data-access/src/service/sites/index.js @@ -0,0 +1,65 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { + addSite, + getSiteByBaseURL, + getSiteByBaseURLWithAuditInfo, + getSiteByBaseURLWithAudits, + getSiteByBaseURLWithLatestAudit, + getSites, + getSitesToAudit, + getSitesWithLatestAudit, removeSite, + updateSite, +} from './accessPatterns.js'; + +export const siteFunctions = (dynamoClient, log) => ({ + getSites: () => getSites( + dynamoClient, + ), + getSitesToAudit: () => getSitesToAudit( + dynamoClient, + ), + getSitesWithLatestAudit: (auditType, sortAuditsAscending) => getSitesWithLatestAudit( + dynamoClient, + log, + auditType, + sortAuditsAscending, + ), + getSiteByBaseURL: (baseUrl) => getSiteByBaseURL( + dynamoClient, + log, + baseUrl, + ), + getSiteByBaseURLWithAuditInfo: (baseUrl, auditType, latestOnly) => getSiteByBaseURLWithAuditInfo( + dynamoClient, + log, + baseUrl, + auditType, + latestOnly, + ), + getSiteByBaseURLWithAudits: (baseUrl, auditType) => getSiteByBaseURLWithAudits( + dynamoClient, + log, + baseUrl, + auditType, + ), + getSiteByBaseURLWithLatestAudit: (baseUrl, auditType) => getSiteByBaseURLWithLatestAudit( + dynamoClient, + log, + baseUrl, + auditType, + ), + addSite: (siteData) => addSite(dynamoClient, log, siteData), + updateSite: (site) => updateSite(dynamoClient, log, site), + removeSite: (siteId) => removeSite(dynamoClient, log, siteId), +}); diff --git a/packages/spacecat-shared-data-access/test/it/auditUtils.js b/packages/spacecat-shared-data-access/test/it/auditUtils.js new file mode 100644 index 000000000..9d1170036 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/auditUtils.js @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { v4 as uuidv4 } from 'uuid'; + +import { randomDate } from './util.js'; + +function generateRandomAudit(siteId, auditType) { + let auditResult = {}; + const auditedAt = randomDate(new Date(2020, 0, 1), new Date()).toISOString(); + const expiresAt = new Date(auditedAt); + expiresAt.setDate(expiresAt.getDate() + 30); + const fullAuditRef = `s3://audit-results/${uuidv4()}.json`; + + function getRandomDecimal(precision) { + return parseFloat(Math.random().toFixed(precision)); + } + + function getRandomInt(max) { + return Math.floor(Math.random() * max); + } + + if (auditType === 'lhs') { + auditResult = { + performance: getRandomDecimal(2), + seo: getRandomDecimal(2), + accessibility: getRandomDecimal(2), + 'best-practices': getRandomDecimal(2), + }; + } else if (auditType === 'cwv') { + auditResult = { + LCP: getRandomInt(4000), // LCP in milliseconds + FID: getRandomInt(100), // FID in milliseconds + CLS: getRandomDecimal(2), // CLS score + }; + } + + return { + siteId, + SK: `${auditType}#${auditedAt}`, + auditType, + auditedAt, + auditResult, + expiresAt: Math.floor(expiresAt.getTime() / 1000), // AWS expects unix epoch in seconds + fullAuditRef, + }; +} + +export { generateRandomAudit }; diff --git a/packages/spacecat-shared-data-access/test/it/db.js b/packages/spacecat-shared-data-access/test/it/db.js new file mode 100644 index 000000000..391712498 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/db.js @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { DynamoDB } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'; + +const dbClient = new DynamoDB({ + endpoint: 'http://127.0.0.1:8000', + region: 'local', + credentials: { + accessKeyId: 'dummy', + secretAccessKey: 'dummy', + }, +}); +const docClient = DynamoDBDocument.from(dbClient); + +export { dbClient, docClient }; diff --git a/packages/spacecat-shared-data-access/test/it/db.test.js b/packages/spacecat-shared-data-access/test/it/db.test.js new file mode 100644 index 000000000..102842a18 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/db.test.js @@ -0,0 +1,349 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import dynamoDbLocal from 'dynamo-db-local'; + +import { isIsoDate, isValidUrl } from '@adobe/spacecat-shared-utils'; +import { sleep } from '../unit/util.js'; +import { createDataAccess } from '../../src/service/index.js'; +import { AUDIT_TYPE_LHS } from '../../src/models/audit.js'; + +import generateSampleData from './generateSampleData.js'; + +const { expect } = chai; +chai.use(chaiAsPromised); + +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.getImsOrgId()).to.be.a('string'); + expect(isIsoDate(site.getCreatedAt())).to.be.true; + expect(isIsoDate(site.getUpdatedAt())).to.be.true; + expect(site.getAudits()).to.be.an('array'); +} + +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(isIsoDate(audit.getAuditedAt())).to.be.true; + expect(audit.getExpiresAt()).to.be.a('date'); + expect(audit.getAuditResult()).to.be.an('object'); + expect(audit.getScores()).to.be.an('object'); + expect(audit.getFullAuditRef()).to.be.a('string'); +} + +describe('DynamoDB Integration Test', async () => { + let dynamoDbLocalProcess; + let dataAccess; + + const NUMBER_OF_SITES = 10; + const NUMBER_OF_AUDITS_PER_TYPE_AND_SITE = 3; + + before(async function () { + this.timeout(20000); + + process.env.AWS_REGION = 'local'; + process.env.AWS_DEFAULT_REGION = 'local'; + process.env.AWS_ACCESS_KEY_ID = 'dummy'; + process.env.AWS_SECRET_ACCESS_KEY = 'dummy'; + + dynamoDbLocalProcess = dynamoDbLocal.spawn({ port: 8000, sharedDb: true }); + + await sleep(1000); // give db time to start up + + await generateSampleData(NUMBER_OF_SITES, NUMBER_OF_AUDITS_PER_TYPE_AND_SITE); + + dataAccess = createDataAccess(console); + }); + + after(() => { + dynamoDbLocalProcess.kill(); + }); + + it('gets sites', async () => { + const sites = await dataAccess.getSites(); + + expect(sites.length).to.equal(NUMBER_OF_SITES); + + sites.forEach((site) => { + checkSite(site); + expect(site.getAudits()).to.be.an('array').that.has.lengthOf(0); + }); + }); + + it('gets sites to audit', async () => { + const sites = await dataAccess.getSitesToAudit(); + + expect(sites.length).to.equal(NUMBER_OF_SITES); + + sites.forEach((baseURL) => { + expect(baseURL).to.be.a('string'); + expect(isValidUrl(baseURL)).to.equal(true); + }); + }); + + it('gets sites with latest audit', async () => { + const sites = await dataAccess.getSitesWithLatestAudit(AUDIT_TYPE_LHS); + + // Every tenth site will not have any audits + expect(sites.length).to.equal(NUMBER_OF_SITES - 1); + + sites.forEach((site) => { + checkSite(site); + expect(site.getAudits()).to.be.an('array').that.has.lengthOf(1); + site.getAudits().forEach((audit) => { + expect(audit.getAuditType()).to.equal(AUDIT_TYPE_LHS); + expect(Object.keys(audit.getScores())).to.have.members( + ['performance', 'seo', 'accessibility', 'best-practices'], + ); + }); + }); + }); + + it('gets site by baseURL', async () => { + const site = await dataAccess.getSiteByBaseURL('https://example1.com'); + + expect(site).to.be.an('object'); + + checkSite(site); + }); + + it('adds a new site', async () => { + const newSiteData = { + baseURL: 'https://newexample.com', + imsOrgId: 'newOrg123', + audits: [], + }; + + const addedSite = await dataAccess.addSite(newSiteData); + + expect(addedSite).to.be.an('object'); + + const newSite = await dataAccess.getSiteByBaseURL(newSiteData.baseURL); + + checkSite(newSite); + + expect(newSite.getId()).to.to.be.a('string'); + expect(newSite.getBaseURL()).to.equal(newSiteData.baseURL); + expect(newSite.getImsOrgId()).to.equal(newSiteData.imsOrgId); + expect(newSite.getAudits()).to.be.an('array').that.is.empty; + }); + + it('updates an existing site', async () => { + const siteToUpdate = await dataAccess.getSiteByBaseURL('https://example1.com'); + const originalUpdatedAt = siteToUpdate.getUpdatedAt(); + const newImsOrgId = 'updatedOrg123'; + + await sleep(10); // Make sure updatedAt is different + + siteToUpdate.updateImsOrgId(newImsOrgId); + + const updatedSite = await dataAccess.updateSite(siteToUpdate); + + expect(updatedSite.getImsOrgId()).to.equal(newImsOrgId); + expect(updatedSite.getUpdatedAt()).to.not.equal(originalUpdatedAt); + }); + + it('retrieves all audits for a site', async () => { + const site = await dataAccess.getSiteByBaseURL('https://example1.com'); + const siteId = site.getId(); + const audits = await dataAccess.getAuditsForSite(siteId); + + expect(audits).to.be.an('array').that.has.lengthOf(NUMBER_OF_AUDITS_PER_TYPE_AND_SITE * 2); + + audits.forEach((audit) => { + checkAudit(audit); + expect(audit.getSiteId()).to.equal(siteId); + }); + }); + + it('retrieves audits of a specific type for a site', async () => { + const site = await dataAccess.getSiteByBaseURL('https://example1.com'); + const siteId = site.getId(); + const auditType = AUDIT_TYPE_LHS; + const audits = await dataAccess.getAuditsForSite(siteId, auditType); + + expect(audits).to.be.an('array').that.has.lengthOf(NUMBER_OF_AUDITS_PER_TYPE_AND_SITE); + + audits.forEach((audit) => { + checkAudit(audit); + expect(audit.getSiteId()).to.equal(siteId); + expect(audit.getAuditType()).to.equal(auditType); + }); + }); + + it('retrieves a specific audit for a site', async () => { + const site = await dataAccess.getSiteByBaseURL('https://example1.com'); + const siteId = site.getId(); + const auditType = AUDIT_TYPE_LHS; + const audits = await dataAccess.getAuditsForSite(site.getId(), auditType); + const auditedAt = audits[0].getAuditedAt(); + + const audit = await dataAccess.getAuditForSite(siteId, auditType, auditedAt); + + checkAudit(audit); + + expect(audit.getSiteId()).to.equal(siteId); + expect(audit.getAuditType()).to.equal(auditType); + expect(audit.getAuditedAt()).to.equal(auditedAt); + }); + + it('returns null for non-existing audit', async () => { + const site = await dataAccess.getSiteByBaseURL('https://example1.com'); + const siteId = site.getId(); + const auditType = 'non-existing-type'; + const auditedAt = '2023-01-01T00:00:00Z'; + + const audit = await dataAccess.getAuditForSite(siteId, auditType, auditedAt); + + expect(audit).to.be.null; + }); + + it('retrieves the latest audits of a specific type', async () => { + const audits = await dataAccess.getLatestAudits(AUDIT_TYPE_LHS, true); + + // Every tenth site will not have any audits + expect(audits).to.be.an('array').that.has.lengthOf(NUMBER_OF_SITES - 1); + + audits.forEach((audit) => { + checkAudit(audit); + expect(audit.getAuditType()).to.equal(AUDIT_TYPE_LHS); + }); + + // verify the sorting order + let lastScoresString = ''; + audits.forEach((audit) => { + const currentScoresString = `${AUDIT_TYPE_LHS}#${Object.keys(audit.getScores()).join('#')}`; + expect(currentScoresString.localeCompare(lastScoresString)).to.be.at.least(0); + lastScoresString = currentScoresString; + }); + }); + + it('retrieves the latest audits in descending order', async () => { + const audits = await dataAccess.getLatestAudits(AUDIT_TYPE_LHS, false); + + expect(audits).to.be.an('array').that.has.lengthOf(NUMBER_OF_SITES - 1); + + // verify the sorting order is descending + // assuming 'z' will be lexicographically after any realistic score string + let lastScoresString = 'z'; + audits.forEach((audit) => { + const currentScoresString = `${AUDIT_TYPE_LHS}#${Object.keys(audit.getScores()).join('#')}`; + expect(currentScoresString.localeCompare(lastScoresString)).to.be.at.most(0); + lastScoresString = currentScoresString; + }); + }); + + it('retrieves the latest audit for a specific site and audit type', async () => { + const site = await dataAccess.getSiteByBaseURL('https://example1.com'); + const siteId = site.getId(); + + const latestAudit = await dataAccess.getLatestAuditForSite(siteId, AUDIT_TYPE_LHS); + + checkAudit(latestAudit); + expect(latestAudit.getSiteId()).to.equal(siteId); + expect(latestAudit.getAuditType()).to.equal(AUDIT_TYPE_LHS); + + const allAudits = await dataAccess.getAuditsForSite(siteId, AUDIT_TYPE_LHS); + const mostRecentAudit = allAudits.reduce((latest, current) => ( + new Date(latest.getAuditedAt()) > new Date(current.getAuditedAt()) ? latest : current + )); + + expect(latestAudit.getAuditedAt()).to.equal(mostRecentAudit.getAuditedAt()); + }); + + it('returns null for a site with no audits of the specified type', async () => { + const site = await dataAccess.getSiteByBaseURL('https://example1.com'); + const siteId = site.getId(); + const auditType = 'non-existing-type'; + + const latestAudit = await dataAccess.getLatestAuditForSite(siteId, auditType); + + expect(latestAudit).to.be.null; + }); + + it('successfully adds a new audit', async () => { + const auditData = { + siteId: 'https://example1.com', + auditType: AUDIT_TYPE_LHS, + auditedAt: new Date().toISOString(), + fullAuditRef: 's3://ref', + auditResult: { + performance: 0, + seo: 0, + accessibility: 0, + 'best-practices': 0, + }, + }; + + const newAudit = await dataAccess.addAudit(auditData); + + checkAudit(newAudit); + expect(newAudit.getSiteId()).to.equal(auditData.siteId); + expect(newAudit.getAuditType()).to.equal(auditData.auditType); + expect(newAudit.getAuditedAt()).to.equal(auditData.auditedAt); + + // Retrieve the latest audit for the site from the latest_audits table + const latestAudit = await dataAccess.getLatestAuditForSite( + auditData.siteId, + auditData.auditType, + ); + + checkAudit(latestAudit); + expect(latestAudit.getSiteId()).to.equal(auditData.siteId); + expect(latestAudit.getAuditType()).to.equal(auditData.auditType); + expect(latestAudit.getAuditedAt()).to.equal(auditData.auditedAt); + }); + + it('throws an error when adding a duplicate audit', async () => { + const auditData = { + siteId: 'https://example1.com', + auditType: AUDIT_TYPE_LHS, + auditedAt: new Date().toISOString(), + fullAuditRef: 's3://ref', + auditResult: { + performance: 0, + seo: 0, + accessibility: 0, + 'best-practices': 0, + }, + }; + + await dataAccess.addAudit(auditData); + + // Try to add the same audit again + await expect(dataAccess.addAudit(auditData)).to.be.rejectedWith('Audit already exists'); + }); + + it('successfully removes a site and its related audits', async () => { + const siteToRemove = await dataAccess.getSiteByBaseURL('https://example1.com'); + const siteId = siteToRemove.getId(); + + await expect(dataAccess.removeSite(siteId)).to.eventually.be.fulfilled; + + const siteAfterRemoval = await dataAccess.getSiteByBaseURL('https://example1.com'); + expect(siteAfterRemoval).to.be.null; + + const auditsAfterRemoval = await dataAccess.getAuditsForSite(siteId); + expect(auditsAfterRemoval).to.be.an('array').that.is.empty; + + const latestAuditAfterRemoval = await dataAccess.getLatestAuditForSite(siteId, AUDIT_TYPE_LHS); + expect(latestAuditAfterRemoval).to.be.null; + }); +}); diff --git a/packages/spacecat-shared-data-access/test/it/generateSampleData.js b/packages/spacecat-shared-data-access/test/it/generateSampleData.js new file mode 100644 index 000000000..60719db65 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/generateSampleData.js @@ -0,0 +1,168 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { v4 as uuidv4 } from 'uuid'; + +import { dbClient, docClient as client } from './db.js'; +import { generateRandomAudit } from './auditUtils.js'; +import { createTable, deleteTable } from './tableOperations.js'; + +import schema from '../../docs/schema.json' assert { type: 'json' }; + +/** + * 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. + */ +async function createTablesFromSchema() { + 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. + */ +async function deleteExistingTables() { + const deletionPromises = ['sites', 'audits', 'latest_audits'] + .map((tableName) => deleteTable(dbClient, tableName)); + await Promise.all(deletionPromises); +} + +/** + * Performs a batch write operation for a specified table in DynamoDB. + * + * @param {string} tableName - The name of the table to perform the batch write operation on. + * @param {Array} items - An array of items to be written to the table. + * + * @example + * // Example usage + * const itemsToWrite = [{ id: '1', data: 'example' }, { id: '2', data: 'sample' }]; + * batchWrite('myTable', itemsToWrite); + */ +async function batchWrite(tableName, items) { + const batchWriteRequests = []; + while (items.length) { + const batch = items.splice(0, 25).map((item) => ({ + PutRequest: { Item: item }, + })); + + batchWriteRequests.push(client.batchWrite({ + RequestItems: { [tableName]: batch }, + })); + } + + await Promise.all(batchWriteRequests); +} + +/** + * Generates audit data for a specific site. + * + * @param {string} siteId - The ID of the site for which to generate audit data. + * @param {Array} auditTypes - An array of audit types to generate data for. + * @param {number} numberOfAuditsPerType - The number of audits to generate for each type. + * @returns {Object} An object containing arrays of audit data and latest audit data for the site. + * + * @example + * // Example usage + * const audits = generateAuditData('site123', ['lhs', 'cwv'], 5); + */ +function generateAuditData(siteId, auditTypes, numberOfAuditsPerType) { + const latestAudits = {}; + const auditData = []; + + for (const type of auditTypes) { + for (let j = 0; j < numberOfAuditsPerType; j += 1) { + const audit = generateRandomAudit(siteId, type); + auditData.push(audit); + + // Update latest audit for each type + if (!latestAudits[type] + || new Date(audit.auditedAt) > new Date(latestAudits[type].auditedAt)) { + latestAudits[type] = audit; + } + } + } + + const latestAuditData = Object.values(latestAudits).map((audit) => { + // Modify the audit data for the latest_audits table + let GSI1SK = `${audit.auditType}#`; + if (audit.auditType === 'lhs') { + GSI1SK += Object.values(audit.auditResult).map((score) => (parseFloat(score) * 100).toFixed(0)).join('#'); + } else { + GSI1SK += Object.values(audit.auditResult).join('#'); + } + + return { + ...audit, + GSI1PK: 'ALL_LATEST_AUDITS', + GSI1SK, + }; + }); + + return { auditData, latestAuditData }; +} + +/** + * Generates sample data for testing purposes. + * + * @param {number} [numberOfSites=10] - The number of sites to generate. + * @param {number} [numberOfAuditsPerType=5] - The number of audits per type to generate + * for each site. + * + * @example + * // Example usage + * generateSampleData(20, 10); // Generates 20 sites with 10 audits per type for each site + */ +export default async function generateSampleData(numberOfSites = 10, numberOfAuditsPerType = 5) { + console.time('Sample data generated in'); + await deleteExistingTables(); + await createTablesFromSchema(); + + const auditTypes = ['lhs', 'cwv']; + const sites = []; + const auditItems = []; + const latestAuditItems = []; + const nowIso = new Date().toISOString(); + + // Generate site data + for (let i = 0; i < numberOfSites; i += 1) { + const siteId = uuidv4(); + sites.push({ + id: siteId, + baseURL: `https://example${i}.com`, + imsOrgId: `${i}-1234@AdobeOrg`, + GSI1PK: 'ALL_SITES', + createdAt: nowIso, + updatedAt: nowIso, + }); + + if (i % 10 !== 0) { // Every tenth site will not have any audits + const latestAudits = generateAuditData(siteId, auditTypes, numberOfAuditsPerType); + auditItems.push(...latestAudits.auditData); + latestAuditItems.push(...latestAudits.latestAuditData); + } + } + + await batchWrite('sites', sites); + await batchWrite('audits', auditItems); + await batchWrite('latest_audits', latestAuditItems); + + console.log(`Generated ${numberOfSites} sites with ${numberOfAuditsPerType} audits per type for each site`); + console.timeEnd('Sample data generated in'); +} diff --git a/packages/spacecat-shared-data-access/test/it/tableOperations.js b/packages/spacecat-shared-data-access/test/it/tableOperations.js new file mode 100644 index 000000000..b7799143d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/tableOperations.js @@ -0,0 +1,161 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb'; + +/** + * Creates a DynamoDB table based on the provided table definition. + * + * The function defines key schema and attribute definitions for the table, + * including the partition key and optional sort key. It also handles the + * configuration of global secondary indexes (GSIs) if provided + * in the table definition. + * + * @param {Object} dbClient - The DynamoDB client instance used for sending commands. + * @param {Object} tableDefinition - An object describing the table to be created. It should contain + * the table name, key attributes, and optionally GSIs. + * + * @example + * // Example of tableDefinition object + * { + * TableName: 'MyTable', + * KeyAttributes: { + * PartitionKey: { AttributeName: 'Id', AttributeType: 'S' }, + * SortKey: { AttributeName: 'SortKey', AttributeType: 'N' } + * }, + * GlobalSecondaryIndexes: [ + * { + * IndexName: 'MyGSI', + * KeyAttributes: { + * PartitionKey: { AttributeName: 'GSIKey', AttributeType: 'S' }, + * SortKey: { AttributeName: 'GSISortKey', AttributeType: 'N' } + * }, + * Projection: { + * ProjectionType: 'ALL' + * } + * } + * ] + * } + */ +async function createTable(dbClient, tableDefinition) { + const keySchema = []; + const attributeDefinitions = []; + + // Define partition key + if (tableDefinition.KeyAttributes.PartitionKey) { + keySchema.push({ AttributeName: tableDefinition.KeyAttributes.PartitionKey.AttributeName, KeyType: 'HASH' }); + attributeDefinitions.push({ + AttributeName: tableDefinition.KeyAttributes.PartitionKey.AttributeName, + AttributeType: tableDefinition.KeyAttributes.PartitionKey.AttributeType, + }); + } + + // Define sort key if present + if (tableDefinition.KeyAttributes.SortKey) { + keySchema.push({ AttributeName: tableDefinition.KeyAttributes.SortKey.AttributeName, KeyType: 'RANGE' }); + attributeDefinitions.push({ + AttributeName: tableDefinition.KeyAttributes.SortKey.AttributeName, + AttributeType: tableDefinition.KeyAttributes.SortKey.AttributeType, + }); + } + + const params = { + TableName: tableDefinition.TableName, + KeySchema: keySchema, + AttributeDefinitions: attributeDefinitions, + BillingMode: 'PAY_PER_REQUEST', // or specify ProvisionedThroughput + }; + + // Add GSI configuration if present + if (tableDefinition.GlobalSecondaryIndexes) { + params.GlobalSecondaryIndexes = tableDefinition.GlobalSecondaryIndexes.map((gsi) => { + // Add GSI key attributes to AttributeDefinitions + if (gsi.KeyAttributes.PartitionKey) { + if (!attributeDefinitions.some( + (attr) => attr.AttributeName === gsi.KeyAttributes.PartitionKey.AttributeName, + ) + ) { + attributeDefinitions.push({ + AttributeName: gsi.KeyAttributes.PartitionKey.AttributeName, + AttributeType: gsi.KeyAttributes.PartitionKey.AttributeType, + }); + } + } + if (gsi.KeyAttributes.SortKey) { + if (!attributeDefinitions.some( + (attr) => attr.AttributeName === gsi.KeyAttributes.SortKey.AttributeName, + ) + ) { + attributeDefinitions.push({ + AttributeName: gsi.KeyAttributes.SortKey.AttributeName, + AttributeType: gsi.KeyAttributes.SortKey.AttributeType, + }); + } + } + + // Define GSI Key Schema + const gsiKeySchema = [ + { AttributeName: gsi.KeyAttributes.PartitionKey.AttributeName, KeyType: 'HASH' }, + gsi.KeyAttributes.SortKey ? { + AttributeName: gsi.KeyAttributes.SortKey.AttributeName, + KeyType: 'RANGE', + } : null, + ].filter(Boolean); + + return { + IndexName: gsi.IndexName, + KeySchema: gsiKeySchema, + Projection: gsi.Projection, + }; + }); + } + + try { + await dbClient.send(new CreateTableCommand(params)); + console.log(`Table ${tableDefinition.TableName} created successfully.`); + } catch (error) { + console.error(`Error creating table ${tableDefinition.TableName}:`, error); + } +} + +/** + * Deletes a specified DynamoDB table. + * + * The function sends a command to delete the table with the given table name. + * It handles the response and logs the result of the operation, including handling the case + * where the table does not exist. + * + * @param {Object} dbClient - The DynamoDB client instance used for sending commands. + * @param {string} tableName - The name of the table to be deleted. + * + * @example + * // Example usage + * deleteTable(dynamoDBClient, 'MyTable'); + */ +async function deleteTable(dbClient, tableName) { + const deleteParams = { + TableName: tableName, + }; + + try { + await dbClient.send(new DeleteTableCommand(deleteParams)); + console.log(`Table ${tableName} deleted successfully.`); + } catch (error) { + if (error.name === 'ResourceNotFoundException') { + console.log(`Table ${tableName} does not exist.`); + } else { + console.error(`Error deleting table ${tableName}:`, error); + } + } +} + +export { createTable, deleteTable }; diff --git a/packages/spacecat-shared-data-access/test/it/util.js b/packages/spacecat-shared-data-access/test/it/util.js new file mode 100644 index 000000000..c2ce6fc12 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/it/util.js @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 randomDate = (start, end) => { + if (start.getTime() >= end.getTime()) { + throw new Error('start must be before end'); + } + return new Date( + start.getTime() + Math.random() * (end.getTime() - start.getTime()), + ); +}; + +// Generates a random decimal number with given precision +const getRandomDecimal = (precision) => parseFloat(Math.random().toFixed(precision)); + +// Generates a random integer up to a given maximum +const getRandomInt = (max) => Math.floor(Math.random() * max); + +export { randomDate, getRandomDecimal, getRandomInt }; diff --git a/packages/spacecat-shared-data-access/test/unit/index.test.js b/packages/spacecat-shared-data-access/test/unit/index.test.js new file mode 100644 index 000000000..893ca5042 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/index.test.js @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect } from 'chai'; +import sinon from 'sinon'; +import dataAccessWrapper from '../../src/index.js'; + +describe('Data Access Wrapper Tests', () => { + let mockFn; + let mockContext; + let mockRequest; + + beforeEach(() => { + mockFn = sinon.stub().resolves('function response'); + mockContext = { log: sinon.spy() }; + mockRequest = {}; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('adds dataAccess to context and calls the wrapped function', async () => { + const wrappedFn = dataAccessWrapper(mockFn); + + const response = await wrappedFn(mockRequest, mockContext); + + expect(mockFn.calledOnceWithExactly(mockRequest, mockContext)).to.be.true; + expect(response).to.equal('function response'); + }); + + it('does not recreate dataAccess if already present in context', async () => { + mockContext.dataAccess = { existingDataAccess: true }; + const wrappedFn = dataAccessWrapper(mockFn); + + await wrappedFn(mockRequest, mockContext); + + expect(mockContext.dataAccess).to.deep.equal({ existingDataAccess: true }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/audit.test.js b/packages/spacecat-shared-data-access/test/unit/models/audit.test.js new file mode 100644 index 000000000..e1a3c82d8 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/audit.test.js @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect } from 'chai'; +import { createAudit } from '../../../src/models/audit.js'; + +const validData = { + siteId: '123', + auditedAt: new Date().toISOString(), + auditType: 'lhs', + auditResult: { + performance: 0.9, + seo: 0.9, + accessibility: 0.9, + 'best-practices': 0.9, + }, + fullAuditRef: 'ref123', +}; + +describe('Audit Model Tests', () => { + describe('Validation Tests', () => { + it('throws an error if siteId is not provided', () => { + expect(() => createAudit({ ...validData, siteId: '' })).to.throw('Site ID must be provided'); + }); + + it('throws an error if auditedAt is not a valid ISO date', () => { + expect(() => createAudit({ ...validData, auditedAt: 'invalid-date' })).to.throw('Audited at must be a valid ISO date'); + }); + + it('throws an error if auditType is not provided', () => { + expect(() => createAudit({ ...validData, auditType: '' })).to.throw('Audit type must be provided'); + }); + + it('throws an error if auditResult is not an object', () => { + expect(() => createAudit({ ...validData, auditResult: 'not-an-object' })).to.throw('Audit result must be an object'); + }); + + it('throws an error if fullAuditRef is not provided', () => { + expect(() => createAudit({ ...validData, fullAuditRef: '' })).to.throw('Full audit ref must be provided'); + }); + }); + + describe('Functionality Tests', () => { + it('creates an audit object with correct properties', () => { + const audit = createAudit(validData); + expect(audit).to.be.an('object'); + expect(audit.getSiteId()).to.equal(validData.siteId); + expect(audit.getAuditedAt()).to.equal(validData.auditedAt); + expect(audit.getAuditType()).to.equal(validData.auditType.toLowerCase()); + expect(audit.getAuditResult()).to.deep.equal(validData.auditResult); + expect(audit.getFullAuditRef()).to.equal(validData.fullAuditRef); + }); + + it('automatically sets expiresAt if not provided', () => { + const audit = createAudit(validData); + expect(audit.getExpiresAt()).to.be.a('Date'); + const expectedDate = new Date(validData.auditedAt); + expectedDate.setDate(expectedDate.getDate() + 30); + expect(audit.getExpiresAt().toDateString()).to.equal(expectedDate.toDateString()); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/base.test.js b/packages/spacecat-shared-data-access/test/unit/models/base.test.js new file mode 100644 index 000000000..8005fff40 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/base.test.js @@ -0,0 +1,68 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import { expect } from 'chai'; +import { Base } from '../../../src/models/base.js'; +import { sleep } from '../util.js'; + +describe('Base Model Tests', () => { + describe('Initialization Tests', () => { + it('should automatically assign a UUID if no id is provided', () => { + const baseEntity = Base(); + expect(baseEntity.getId()).to.match(/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/); + }); + + it('should retain the provided id if one is provided', () => { + const id = 'test-id'; + const baseEntity = Base({ id }); + expect(baseEntity.getId()).to.equal(id); + }); + }); + + describe('Getter Method Tests', () => { + it('correctly returns the createdAt date if provided', () => { + const createdAt = new Date().toISOString(); + const baseEntity = Base({ createdAt }); + expect(baseEntity.getCreatedAt()).to.equal(createdAt); + }); + + it('correctly returns the updatedAt date if provided', () => { + const updatedAt = new Date().toISOString(); + const baseEntity = Base({ updatedAt }); + expect(baseEntity.getUpdatedAt()).to.equal(updatedAt); + }); + }); + + describe('Timestamp Tests', () => { + it('should set createdAt and updatedAt for new records', () => { + const baseEntity = Base(); + expect(isIsoDate(baseEntity.getCreatedAt())).to.be.true; + expect(isIsoDate(baseEntity.getUpdatedAt())).to.be.true; + expect(baseEntity.getCreatedAt()).to.equal(baseEntity.getUpdatedAt()); + }); + + it('should update updatedAt using touch method', async () => { + const baseEntity = Base(); + const initialUpdatedAt = baseEntity.getUpdatedAt(); + + await sleep(10); + + baseEntity.touch(); + + expect(baseEntity.getUpdatedAt()).to.not.equal(initialUpdatedAt); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/site.test.js b/packages/spacecat-shared-data-access/test/unit/models/site.test.js new file mode 100644 index 000000000..72adcd26a --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/site.test.js @@ -0,0 +1,95 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect } from 'chai'; +import { createSite } from '../../../src/models/site.js'; +import { sleep } from '../util.js'; + +// Constants for testing +const validData = { + baseURL: 'https://www.example.com', + imsOrgId: 'org123', +}; + +describe('Site Model Tests', () => { + describe('Validation Tests', () => { + it('throws an error if baseURL is not a valid URL', () => { + expect(() => createSite({ ...validData, baseURL: 'invalid-url' })).to.throw('Base URL must be a valid URL'); + }); + + it('creates a site object with valid baseURL', () => { + const site = createSite({ ...validData }); + expect(site).to.be.an('object'); + expect(site.getBaseURL()).to.equal(validData.baseURL); + }); + }); + + describe('Site Object Functionality', () => { + let site; + + beforeEach(() => { + site = createSite(validData); + }); + + // see TODO in src/models/site.js + /* it('updates baseURL correctly', () => { + const newURL = 'https://www.newexample.com'; + site.updateBaseURL(newURL); + expect(site.getBaseURL()).to.equal(newURL); + }); + + it('throws an error when updating with an invalid baseURL', () => { + expect(() => site.updateBaseURL('invalid-url')).to.throw('Base URL must be a valid URL'); + }); + */ + + it('updates imsOrgId correctly', () => { + const newImsOrgId = 'newOrg123'; + site.updateImsOrgId(newImsOrgId); + expect(site.getImsOrgId()).to.equal(newImsOrgId); + }); + + it('throws an error when updating with an empty imsOrgId', () => { + expect(() => site.updateImsOrgId('')).to.throw('IMS Org ID must be provided'); + }); + + it('sets audits correctly', () => { + const audits = [{ id: 'audit1' }, { id: 'audit2' }]; + site.setAudits(audits); + expect(site.getAudits()).to.deep.equal(audits); + }); + + // see TODO in src/models/site.js + /* + it('updates updatedAt when base URL is updated', async () => { + const initialUpdatedAt = site.getUpdatedAt(); + + await sleep(10); + + site.updateBaseURL('https://www.newexample.com'); + + expect(site.getUpdatedAt()).to.not.equal(initialUpdatedAt); + }); */ + + it('updates updatedAt when imsOrgId is updated', async () => { + const initialUpdatedAt = site.getUpdatedAt(); + + await sleep(20); + + site.updateImsOrgId('newOrg123'); + + expect(site.getUpdatedAt()).to.not.equal(initialUpdatedAt); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/service/audits/index.test.js b/packages/spacecat-shared-data-access/test/unit/service/audits/index.test.js new file mode 100644 index 000000000..ec064c789 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/service/audits/index.test.js @@ -0,0 +1,241 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; + +import { auditFunctions } from '../../../../src/service/audits/index.js'; + +chai.use(chaiAsPromised); + +const { expect } = chai; + +describe('Audit Access Pattern Tests', () => { + describe('Audit Functions Export Tests', () => { + const mockDynamoClient = {}; + const mockLog = {}; + + const exportedFunctions = auditFunctions(mockDynamoClient, mockLog); + + it('exports getAuditsForSite function', () => { + expect(exportedFunctions).to.have.property('getAuditsForSite'); + expect(exportedFunctions.getAuditsForSite).to.be.a('function'); + }); + + it('exports getLatestAudits function', () => { + expect(exportedFunctions).to.have.property('getLatestAudits'); + expect(exportedFunctions.getLatestAudits).to.be.a('function'); + }); + + it('exports getLatestAuditForSite function', () => { + expect(exportedFunctions).to.have.property('getLatestAuditForSite'); + expect(exportedFunctions.getLatestAuditForSite).to.be.a('function'); + }); + }); + + describe('Audit Functions Tests', () => { + let mockDynamoClient; + let mockLog; + let exportedFunctions; + + beforeEach(() => { + mockDynamoClient = { + query: sinon.stub().returns(Promise.resolve([])), + removeItem: sinon.stub().resolves(), + }; + mockLog = { + log: sinon.stub(), + error: sinon.stub(), + }; + + exportedFunctions = auditFunctions(mockDynamoClient, mockLog); + }); + + it('calls getAuditsForSite and return an array', async () => { + const result = await exportedFunctions.getAuditsForSite('siteId', 'auditType'); + expect(result).to.be.an('array'); + expect(mockDynamoClient.query.called).to.be.true; + }); + + it('calls getLatestAudits and return an array', async () => { + const result = await exportedFunctions.getLatestAudits('auditType', true); + expect(result).to.be.an('array'); + expect(mockDynamoClient.query.called).to.be.true; + }); + + it('calls getLatestAuditForSite and return an array', async () => { + const result = await exportedFunctions.getLatestAuditForSite('siteId', 'auditType'); + expect(result).to.be.null; + expect(mockDynamoClient.query.called).to.be.true; + }); + }); + + describe('getAuditForSite Tests', () => { + let mockDynamoClient; + let mockLog; + let exportedFunctions; + + beforeEach(() => { + mockDynamoClient = { + query: sinon.stub().returns(Promise.resolve([])), + }; + mockLog = { log: sinon.stub() }; + exportedFunctions = auditFunctions(mockDynamoClient, mockLog); + }); + + it('successfully retrieves an audit for a site', async () => { + const mockAuditData = [{ + siteId: 'siteId', + auditType: 'lhs', + auditedAt: new Date().toISOString(), + auditResult: { + performance: 0.9, + seo: 0.9, + accessibility: 0.9, + 'best-practices': 0.9, + }, + fullAuditRef: 'https://someurl.com', + }]; + mockDynamoClient.query.returns(Promise.resolve(mockAuditData)); + + const result = await exportedFunctions.getAuditForSite('siteId', 'auditType', 'auditedAt'); + expect(result).to.not.be.null; + expect(result.getScores()).to.be.an('object'); + expect(mockDynamoClient.query.calledOnce).to.be.true; + }); + + it('returns null if no audit is found for a site', async () => { + mockDynamoClient.query.returns(Promise.resolve([])); + + const result = await exportedFunctions.getAuditForSite('siteId', 'auditType', 'auditedAt'); + expect(result).to.be.null; + expect(mockDynamoClient.query.calledOnce).to.be.true; + }); + }); + + describe('addAudit Tests', () => { + let mockDynamoClient; + let mockLog; + let exportedFunctions; + + const auditData = { + siteId: 'siteId', + auditType: 'lhs', + auditedAt: new Date().toISOString(), + auditResult: { + performance: 0.9, + seo: 0.9, + accessibility: 0.9, + 'best-practices': 0.9, + }, + fullAuditRef: 'https://someurl.com', + }; + + beforeEach(() => { + mockDynamoClient = { + query: sinon.stub().returns(Promise.resolve([])), + putItem: sinon.stub().returns(Promise.resolve()), + removeItem: sinon.stub().returns(Promise.resolve()), + }; + mockLog = { + log: sinon.stub(), + error: sinon.stub(), + }; + exportedFunctions = auditFunctions(mockDynamoClient, mockLog); + }); + + it('successfully adds a new audit', async () => { + const result = await exportedFunctions.addAudit(auditData); + // Once for 'audits' and once for 'latest_audits' + expect(mockDynamoClient.putItem.calledTwice).to.be.true; + expect(result.getSiteId()).to.equal(auditData.siteId); + expect(result.getAuditType()).to.equal(auditData.auditType); + expect(result.getAuditedAt()).to.equal(auditData.auditedAt); + expect(result.getAuditResult()).to.deep.equal(auditData.auditResult); + expect(result.getFullAuditRef()).to.equal(auditData.fullAuditRef); + expect(result.getScores()).to.be.an('object'); + }); + + it('throws an error if audit already exists', async () => { + mockDynamoClient.query.returns(Promise.resolve([auditData])); + + await expect(exportedFunctions.addAudit(auditData)).to.be.rejectedWith('Audit already exists'); + }); + + it('throws an error for unknown audit type', async () => { + const invalidAuditData = { + ...auditData, + auditType: 'unknownType', // An unknown audit type + }; + + await expect(exportedFunctions.addAudit(invalidAuditData)).to.be.rejectedWith('Unknown audit type'); + }); + + it('throws an error if an expected property is missing in audit results', async () => { + const incompleteAuditData = { + ...auditData, + auditResult: { + performance: 0.9, + seo: 0.9, + // 'accessibility' and 'best-practices' are missing + }, + }; + + await expect(exportedFunctions.addAudit(incompleteAuditData)).to.be.rejectedWith('Missing expected property'); + }); + + it('should remove all audits and latest audits for a site', async () => { + const mockAuditData = [{ + siteId: 'siteId', + auditType: 'lhs', + auditedAt: new Date().toISOString(), + auditResult: { + performance: 0.9, + seo: 0.9, + accessibility: 0.9, + 'best-practices': 0.9, + }, + fullAuditRef: 'https://someurl.com', + }]; + mockDynamoClient.query.returns(Promise.resolve(mockAuditData)); + + await exportedFunctions.removeAuditsForSite('test-id'); + + expect(mockDynamoClient.query.calledTwice).to.be.true; + expect(mockDynamoClient.removeItem.calledTwice).to.be.true; + }); + + it('should log an error if the removal fails', async () => { + const mockAuditData = [{ + siteId: 'siteId', + auditType: 'lhs', + auditedAt: new Date().toISOString(), + auditResult: { + performance: 0.9, + seo: 0.9, + accessibility: 0.9, + 'best-practices': 0.9, + }, + fullAuditRef: 'https://someurl.com', + }]; + mockDynamoClient.query.returns(Promise.resolve(mockAuditData)); + + const errorMessage = 'Failed to delete item'; + mockDynamoClient.removeItem.rejects(new Error(errorMessage)); + + await expect(exportedFunctions.removeAuditsForSite('some-id')).to.be.rejectedWith(errorMessage); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/service/index.test.js b/packages/spacecat-shared-data-access/test/unit/service/index.test.js new file mode 100644 index 000000000..2c2f537ed --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/service/index.test.js @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { expect } from 'chai'; +import { createDataAccess } from '../../../src/service/index.js'; + +describe('Data Access Object Tests', () => { + const auditFunctions = [ + 'addAudit', + 'getAuditForSite', + 'getAuditsForSite', + 'getLatestAudits', + 'getLatestAuditForSite', + 'removeAuditsForSite', + ]; + const siteFunctions = [ + 'addSite', + 'updateSite', + 'removeSite', + 'getSites', + 'getSitesToAudit', + 'getSitesWithLatestAudit', + 'getSiteByBaseURL', + 'getSiteByBaseURLWithAuditInfo', + 'getSiteByBaseURLWithAudits', + 'getSiteByBaseURLWithLatestAudit', + ]; + + let dao; + + before(() => { + dao = createDataAccess(); + }); + + it('contains all known audit functions', () => { + auditFunctions.forEach((funcName) => { + expect(dao).to.have.property(funcName); + }); + }); + + it('contains all known site functions', () => { + siteFunctions.forEach((funcName) => { + expect(dao).to.have.property(funcName); + }); + }); + + it('does not contain any unexpected functions', () => { + const expectedFunctions = new Set([...auditFunctions, ...siteFunctions]); + Object.keys(dao).forEach((funcName) => { + expect(expectedFunctions).to.include(funcName); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/service/sites/index.test.js b/packages/spacecat-shared-data-access/test/unit/service/sites/index.test.js new file mode 100644 index 000000000..6082a81a5 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/service/sites/index.test.js @@ -0,0 +1,353 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; + +import { siteFunctions } from '../../../../src/service/sites/index.js'; +import { createSite } from '../../../../src/models/site.js'; + +chai.use(chaiAsPromised); + +const { expect } = chai; + +describe('Site Access Pattern Tests', () => { + describe('Site Functions Export Tests', () => { + const mockDynamoClient = {}; + const mockLog = {}; + + const exportedFunctions = siteFunctions(mockDynamoClient, mockLog); + + it('exports getSites function', () => { + expect(exportedFunctions).to.have.property('getSites'); + expect(exportedFunctions.getSites).to.be.a('function'); + }); + + it('exports getSitesToAudit function', () => { + expect(exportedFunctions).to.have.property('getSitesToAudit'); + expect(exportedFunctions.getSitesToAudit).to.be.a('function'); + }); + + it('exports getSitesWithLatestAudit function', () => { + expect(exportedFunctions).to.have.property('getSitesWithLatestAudit'); + expect(exportedFunctions.getSitesWithLatestAudit).to.be.a('function'); + }); + + it('exports getSiteByBaseURL function', () => { + expect(exportedFunctions).to.have.property('getSiteByBaseURL'); + expect(exportedFunctions.getSiteByBaseURL).to.be.a('function'); + }); + + it('exports getSiteByBaseURLWithAuditInfo function', () => { + expect(exportedFunctions).to.have.property('getSiteByBaseURLWithAuditInfo'); + expect(exportedFunctions.getSiteByBaseURLWithAuditInfo).to.be.a('function'); + }); + + it('exports getSiteByBaseURLWithAudits function', () => { + expect(exportedFunctions).to.have.property('getSiteByBaseURLWithAudits'); + expect(exportedFunctions.getSiteByBaseURLWithAudits).to.be.a('function'); + }); + + it('exports getSiteByBaseURLWithLatestAudit function', () => { + expect(exportedFunctions).to.have.property('getSiteByBaseURLWithLatestAudit'); + expect(exportedFunctions.getSiteByBaseURLWithLatestAudit).to.be.a('function'); + }); + }); + + describe('Site Functions Tests', () => { + let mockDynamoClient; + let mockLog; + let exportedFunctions; + + beforeEach(() => { + mockDynamoClient = { + query: sinon.stub().returns(Promise.resolve([])), + getItem: sinon.stub().returns(Promise.resolve(null)), + }; + mockLog = { log: sinon.stub() }; + + exportedFunctions = siteFunctions(mockDynamoClient, mockLog); + }); + + it('calls getSites and returns an array', async () => { + const result = await exportedFunctions.getSites(); + expect(result).to.be.an('array'); + expect(mockDynamoClient.query.called).to.be.true; + }); + + it('calls getSitesToAudit and returns an array', async () => { + const result = await exportedFunctions.getSitesToAudit(); + expect(result).to.be.an('array'); + expect(mockDynamoClient.query.called).to.be.true; + }); + + it('calls getSitesWithLatestAudit and returns an array', async () => { + const result = await exportedFunctions.getSitesWithLatestAudit(); + expect(result).to.be.an('array'); + expect(mockDynamoClient.query.called).to.be.true; + }); + + it('calls getSitesWithLatestAudit and handles latestAudits', async () => { + const mockSiteData = [{ + id: 'site1', + baseURL: 'https://example.com', + }]; + + const mockAuditData = [{ + siteId: 'site1', + auditType: 'lhs', + auditedAt: new Date().toISOString(), + auditResult: { + performance: 0.9, + seo: 0.9, + accessibility: 0.9, + 'best-practices': 0.9, + }, + fullAuditRef: 'https://example.com', + }]; + + mockDynamoClient.query.onFirstCall().resolves(mockSiteData); + mockDynamoClient.query.onSecondCall().resolves(mockAuditData); + + const result = await exportedFunctions.getSitesWithLatestAudit('lhs'); + expect(result).to.be.an('array').that.has.lengthOf(1); + }); + + it('calls getSitesWithLatestAudit and handles empty latestAudits', async () => { + const mockSiteData = [{ + id: 'site1', + baseURL: 'https://example.com', + }]; + + const mockAuditData = []; + + mockDynamoClient.query.onFirstCall().resolves(mockSiteData); + mockDynamoClient.query.onSecondCall().resolves(mockAuditData); + + const result = await exportedFunctions.getSitesWithLatestAudit('auditType'); + expect(result).to.be.an('array').that.is.empty; + }); + + it('calls getSiteByBaseURL and returns an array/object', async () => { + const result = await exportedFunctions.getSiteByBaseURL(); + expect(result).to.be.null; + expect(mockDynamoClient.query.called).to.be.true; + }); + + it('calls getSiteByBaseURLWithAuditInfo and returns an array/object', async () => { + const result = await exportedFunctions.getSiteByBaseURLWithAuditInfo(); + expect(result).to.be.null; + expect(mockDynamoClient.query.called).to.be.true; + }); + + it('calls getSiteByBaseURLWithAuditInfo and returns null when site is undefined', async () => { + mockDynamoClient.query.resolves([]); + + const result = await exportedFunctions.getSiteByBaseURLWithAuditInfo('baseUrl', 'auditType'); + expect(result).to.be.null; + }); + + it('calls getSiteByBaseURLWithAuditInfo and assigns latest audit when latestOnly is true', async () => { + const mockSiteData = [{ + id: 'site1', + baseURL: 'https://example.com', + }]; + + const mockLatestAuditData = [{ + siteId: 'site1', + auditType: 'lhs', + auditedAt: new Date().toISOString(), + auditResult: { + performance: 0.9, + seo: 0.9, + accessibility: 0.9, + 'best-practices': 0.9, + }, + fullAuditRef: 'https://example.com', + }]; + + mockDynamoClient.query.onFirstCall().resolves(mockSiteData); + mockDynamoClient.query.onSecondCall().resolves(mockLatestAuditData); + + const result = await exportedFunctions.getSiteByBaseURLWithAuditInfo('https://example.com', 'lhs', true); + const audits = result.getAudits(); + expect(audits).to.be.an('array').with.lengthOf(1); + + const audit = audits[0]; + expect(audit.getId()).to.be.a('string').that.is.not.empty; + expect(audit.getSiteId()).to.equal(mockLatestAuditData[0].siteId); + expect(audit.getAuditType()).to.equal(mockLatestAuditData[0].auditType); + expect(audit.getAuditedAt()).to.equal(mockLatestAuditData[0].auditedAt); + expect(audit.getAuditResult()).to.deep.equal(mockLatestAuditData[0].auditResult); + expect(audit.getFullAuditRef()).to.equal(mockLatestAuditData[0].fullAuditRef); + }); + + it('calls getSiteByBaseURLWithAuditInfo and assigns all audits when latestOnly is false', async () => { + const mockSiteData = [{ + id: 'site1', + baseURL: 'https://example.com', + }]; + + const mockLatestAuditData = [{ + siteId: 'site1', + auditType: 'lhs', + auditedAt: new Date().toISOString(), + auditResult: { + performance: 0.9, + seo: 0.9, + accessibility: 0.9, + 'best-practices': 0.9, + }, + fullAuditRef: 'https://example.com', + }, + { + siteId: 'site1', + auditType: 'lhs', + auditedAt: new Date().toISOString(), + auditResult: { + performance: 0.9, + seo: 0.9, + accessibility: 0.9, + 'best-practices': 0.9, + }, + fullAuditRef: 'https://example2.com', + }]; + + mockDynamoClient.query.onFirstCall().resolves(mockSiteData); + mockDynamoClient.query.onSecondCall().resolves(mockLatestAuditData); + + const result = await exportedFunctions.getSiteByBaseURLWithAuditInfo('baseUrl', 'lhs', false); + const audits = result.getAudits(); + expect(audits).to.be.an('array').with.lengthOf(2); + + for (let i = 0; i < mockLatestAuditData.length; i += 1) { + const mockAudit = mockLatestAuditData[i]; + const audit = audits[i]; + + expect(audit.getId()).to.be.a('string').that.is.not.empty; + expect(audit.getSiteId()).to.equal(mockAudit.siteId); + expect(audit.getAuditType()).to.equal(mockAudit.auditType); + expect(audit.getAuditedAt()).to.equal(mockAudit.auditedAt); + expect(audit.getAuditResult()).to.deep.equal(mockAudit.auditResult); + expect(audit.getFullAuditRef()).to.equal(mockAudit.fullAuditRef); + } + }); + + it('calls getSiteByBaseURLWithAudits and returns an array/object', async () => { + const result = await exportedFunctions.getSiteByBaseURLWithAudits(); + expect(result).to.be.null; + expect(mockDynamoClient.query.called).to.be.true; + }); + + it('calls getSiteByBaseURLWithLatestAudit and returns an array/object', async () => { + const result = await exportedFunctions.getSiteByBaseURLWithLatestAudit(); + expect(result).to.be.null; + expect(mockDynamoClient.query.called).to.be.true; + }); + + describe('addSite Tests', () => { + beforeEach(() => { + mockDynamoClient = { + query: sinon.stub().returns(Promise.resolve([])), + putItem: sinon.stub().returns(Promise.resolve()), + }; + mockLog = { log: sinon.stub() }; + exportedFunctions = siteFunctions(mockDynamoClient, mockLog); + }); + + it('adds a new site successfully', async () => { + const siteData = { baseURL: 'https://newsite.com' }; + const result = await exportedFunctions.addSite(siteData); + expect(mockDynamoClient.putItem.calledOnce).to.be.true; + expect(result.getBaseURL()).to.equal(siteData.baseURL); + expect(result.getId()).to.be.a('string'); + expect(result.getAudits()).to.be.an('array').that.is.empty; + }); + + it('throws an error if site already exists', async () => { + const siteData = { baseURL: 'https://existingsite.com' }; + mockDynamoClient.query.returns(Promise.resolve([siteData])); + + await expect(exportedFunctions.addSite(siteData)).to.be.rejectedWith('Site already exists'); + }); + }); + }); + + describe('updateSite Tests', () => { + let mockDynamoClient; + let mockLog; + let exportedFunctions; + + beforeEach(() => { + mockDynamoClient = { + query: sinon.stub().returns(Promise.resolve([])), + putItem: sinon.stub().returns(Promise.resolve()), + }; + mockLog = { log: sinon.stub() }; + exportedFunctions = siteFunctions(mockDynamoClient, mockLog); + }); + + it('updates an existing site successfully', async () => { + const siteData = { baseURL: 'https://existingsite.com' }; + mockDynamoClient.query.returns(Promise.resolve([siteData])); + + const site = await exportedFunctions.getSiteByBaseURL(siteData.baseURL); + // site.updateBaseURL('https://newsite.com'); + site.updateImsOrgId('newOrg123'); + + const result = await exportedFunctions.updateSite(site); + expect(mockDynamoClient.putItem.calledOnce).to.be.true; + expect(result.getBaseURL()).to.equal(site.getBaseURL()); + expect(result.getImsOrgId()).to.equal(site.getImsOrgId()); + }); + + it('throws an error if site does not exist', async () => { + const site = createSite({ baseURL: 'https://nonexistingsite.com' }); + await expect(exportedFunctions.updateSite(site)).to.be.rejectedWith('Site not found'); + }); + }); + + describe('removeSite Tests', () => { + let mockDynamoClient; + let mockLog; + let exportedFunctions; + + beforeEach(() => { + mockDynamoClient = { + query: sinon.stub().returns(Promise.resolve([])), + removeItem: sinon.stub().returns(Promise.resolve()), + }; + mockLog = { + log: sinon.stub(), + error: sinon.stub(), + }; + exportedFunctions = siteFunctions(mockDynamoClient, mockLog); + }); + + it('removes the site and its related audits', async () => { + await exportedFunctions.removeSite('some-id'); + + expect(mockDynamoClient.removeItem.calledOnce).to.be.true; + }); + + it('logs an error and reject if the site removal fails', async () => { + const errorMessage = 'Failed to delete site'; + mockDynamoClient.removeItem.rejects(new Error(errorMessage)); + + await expect(exportedFunctions.removeSite('some-id')).to.be.rejectedWith(errorMessage); + expect(mockLog.error.calledOnce).to.be.true; + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/util.js b/packages/spacecat-shared-data-access/test/unit/util.js new file mode 100644 index 000000000..b416000e1 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/util.js @@ -0,0 +1,17 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT 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 async function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/packages/spacecat-shared-dynamo/package.json b/packages/spacecat-shared-dynamo/package.json index 806471f2e..0b0f7b1db 100644 --- a/packages/spacecat-shared-dynamo/package.json +++ b/packages/spacecat-shared-dynamo/package.json @@ -31,7 +31,7 @@ "dependencies": { "@aws-sdk/client-dynamodb": "3.454.0", "@aws-sdk/lib-dynamodb": "3.454.0", - "@adobe/spacecat-shared-utils": "1.0.1" + "@adobe/spacecat-shared-utils": "1.1.0" }, "devDependencies": { "chai": "4.3.10"