Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 60 additions & 20 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
## Constants

<dl>
<dt><a href="#createDataAccess">createDataAccess</a> ⇒ <code>object</code></dt>
<dd><p>Creates a data access object.</p>
</dd>
</dl>

## Functions

<dl>
<dt><a href="#createClient">createClient(log, dbClient, docClient)</a> ⇒ <code>Object</code></dt>
<dd><p>Creates a client object for interacting with DynamoDB.</p>
</dd>
<dt><a href="#createResponse">createResponse(body, status, headers)</a> ⇒ <code>Response</code></dt>
<dd><p>Creates a response with a JSON body. Defaults to 200 status.</p>
</dd>
<dt><a href="#isArray">isArray(value)</a> ⇒ <code>boolean</code></dt>
<dd><p>Determines if the given parameter is an array.</p>
</dd>
Expand Down Expand Up @@ -55,20 +50,14 @@ following UTC time offsets format.</p>
<dt><a href="#arrayEquals">arrayEquals(a, b)</a> ⇒ <code>boolean</code></dt>
<dd><p>Compares two arrays for equality. Supports primitive array item types only.</p>
</dd>
<dt><a href="#dateAfterDays">dateAfterDays(days)</a> ⇒ <code>Date</code></dt>
<dd><p>Calculates the date after a specified number of days from the current date.</p>
</dd>
<dt><a href="#resolveSecretsName">resolveSecretsName(opts, ctx, defaultPath)</a> ⇒ <code>string</code></dt>
<dd><p>Resolves the name of the secret based on the function version.</p>
</dd>
</dl>

<a name="createDataAccess"></a>

## createDataAccess ⇒ <code>object</code>
Creates a data access object.

**Kind**: global constant
**Returns**: <code>object</code> - data access object

| Param | Type | Description |
| --- | --- | --- |
| log | <code>Logger</code> | logger |

<a name="createClient"></a>

## createClient(log, dbClient, docClient) ⇒ <code>Object</code>
Expand All @@ -83,6 +72,20 @@ Creates a client object for interacting with DynamoDB.
| dbClient | <code>DynamoDB</code> | The AWS SDK DynamoDB client instance. |
| docClient | <code>DynamoDBDocument</code> | The AWS SDK DynamoDB Document client instance. |

<a name="createResponse"></a>

## createResponse(body, status, headers) ⇒ <code>Response</code>
Creates a response with a JSON body. Defaults to 200 status.

**Kind**: global function
**Returns**: <code>Response</code> - Response.

| Param | Type | Description |
| --- | --- | --- |
| body | <code>object</code> | JSON body. |
| status | <code>number</code> | Optional status code. |
| headers | <code>object</code> | Optional headers. |

<a name="isArray"></a>

## isArray(value) ⇒ <code>boolean</code>
Expand Down Expand Up @@ -248,3 +251,40 @@ Compares two arrays for equality. Supports primitive array item types only.
| a | <code>Array</code> | The first array to compare. |
| b | <code>Array</code> | The second array to compare. |

<a name="dateAfterDays"></a>

## dateAfterDays(days) ⇒ <code>Date</code>
Calculates the date after a specified number of days from the current date.

**Kind**: global function
**Returns**: <code>Date</code> - A new Date object representing the calculated date after the specified days.
**Throws**:

- <code>TypeError</code> If the provided 'days' parameter is not a number.
- <code>RangeError</code> If the calculated date is outside the valid JavaScript date range.


| Param | Type | Description |
| --- | --- | --- |
| days | <code>number</code> | The number of days to add to the current date. |

**Example**
```js
// Get the date 7 days from now
const sevenDaysLater = dateAfterDays(7);
console.log(sevenDaysLater); // Outputs a Date object representing the date 7 days from now
```
<a name="resolveSecretsName"></a>

## resolveSecretsName(opts, ctx, defaultPath) ⇒ <code>string</code>
Resolves the name of the secret based on the function version.

**Kind**: global function
**Returns**: <code>string</code> - - The resolved secret name.

| Param | Type | Description |
| --- | --- | --- |
| opts | <code>Object</code> | The options object, not used in this implementation. |
| ctx | <code>Object</code> | The context object containing the function version. |
| defaultPath | <code>string</code> | The default path for the secret. |

4 changes: 4 additions & 0 deletions packages/spacecat-shared-data-access/docs/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@
"AttributeName": "auditResult",
"AttributeType": "M"
},
{
"AttributeName": "previousAuditResult",
"AttributeType": "M"
},
{
"AttributeName": "expiresAt",
"AttributeType": "N"
Expand Down
15 changes: 12 additions & 3 deletions packages/spacecat-shared-data-access/src/dto/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* governing permissions and limitations under the License.
*/

import { isObject } from '@adobe/spacecat-shared-utils';

import { createAudit } from '../models/audit.js';

function parseEpochToDate(epochInSeconds) {
Expand All @@ -28,10 +30,10 @@ export const AuditDto = {
/**
* Converts an Audit object into a DynamoDB item.
* @param {Readonly<Audit>} audit - Audit object.
* @param {boolean} latestAudit - If true, returns the latest audit flavor.
* @param {boolean} isLatestAudit - If true, returns the latest audit flavor.
* @returns {{siteId, auditedAt, auditResult, auditType, expiresAt, fullAuditRef, SK: string}}
*/
toDynamoItem: (audit, latestAudit = false) => {
toDynamoItem: (audit, isLatestAudit = false) => {
const GSI1PK = 'ALL_LATEST_AUDITS';
let GSI1SK;

Expand All @@ -41,7 +43,12 @@ export const AuditDto = {
GSI1SK = `${audit.getAuditType()}#${Object.values(audit.getScores()).join('#')}`;
}

const latestAuditProps = latestAudit ? { GSI1PK, GSI1SK } : {};
const latestAuditProps = isLatestAudit ? {
GSI1PK,
GSI1SK,
...(isObject(audit.getPreviousAuditResult())
&& { previousAuditResult: audit.getPreviousAuditResult() }),
} : {};

return {
siteId: audit.getSiteId(),
Expand Down Expand Up @@ -70,6 +77,8 @@ export const AuditDto = {
expiresAt: parseEpochToDate(dynamoItem.expiresAt),
fullAuditRef: dynamoItem.fullAuditRef,
isLive: dynamoItem.isLive,
...(isObject(dynamoItem.previousAuditResult)
&& { previousAuditResult: dynamoItem.previousAuditResult }),
};

return createAudit(auditData);
Expand Down
13 changes: 13 additions & 0 deletions packages/spacecat-shared-data-access/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ export interface Audit {
*/
getAuditResult: () => object;

/**
* Retrieves the result of the previous audit.
* This serves for comparison purposes.
* @returns {object|null} The parsed audit result.
*/
getPreviousAuditResult: () => object | null;

/**
* Sets the result of the previous audit.
* @param {object} result The parsed audit result.
*/
setPreviousAuditResult: (result: object) => void;

/**
* Retrieves the type of the audit.
* @returns {object} The audit type.
Expand Down
17 changes: 17 additions & 0 deletions packages/spacecat-shared-data-access/src/models/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const validateScores = (auditResult, auditType) => {
return true;
}

if (!isObject(auditResult.scores)) {
throw new Error(`Missing scores property for audit type '${auditType}'`);
}

const expectedProperties = AUDIT_TYPE_PROPERTIES[auditType];
if (!expectedProperties) {
throw new Error(`Unknown audit type: ${auditType}`);
Expand Down Expand Up @@ -66,6 +70,11 @@ const Audit = (data = {}) => {
self.getFullAuditRef = () => self.state.fullAuditRef;
self.isLive = () => self.state.isLive;
self.isError = () => hasText(self.getAuditResult().runtimeError?.code);
self.getPreviousAuditResult = () => self.state.previousAuditResult;
self.setPreviousAuditResult = (previousAuditResult) => {
validateScores(previousAuditResult, self.getAuditType());
self.state.previousAuditResult = previousAuditResult;
};
self.getScores = () => self.getAuditResult().scores;

return Object.freeze(self);
Expand Down Expand Up @@ -98,6 +107,14 @@ export const createAudit = (data) => {

validateScores(data.auditResult, data.auditType);

if (data.previousAuditResult && !isObject(data.previousAuditResult)) {
throw new Error('Previous audit result must be an object');
}

if (data.previousAuditResult) {
validateScores(data.previousAuditResult, data.auditType);
}

if (!hasText(newState.fullAuditRef)) {
throw new Error('Full audit ref must be provided');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,25 +184,40 @@ export const addAudit = async (
log,
auditData,
) => {
const audit = createAudit(auditData);
const newAudit = createAudit(auditData);
const existingAudit = await getAuditForSite(
dynamoClient,
config,
log,
audit.getSiteId(),
audit.getAuditType(),
audit.getAuditedAt(),
newAudit.getSiteId(),
newAudit.getAuditType(),
newAudit.getAuditedAt(),
);

if (isObject(existingAudit)) {
throw new Error('Audit already exists');
}

const latestAudit = await getLatestAuditForSite(
dynamoClient,
config,
log,
newAudit.getSiteId(),
newAudit.getAuditType(),
);

if (isObject(latestAudit)) {
newAudit.setPreviousAuditResult(latestAudit.getAuditResult());
}

// TODO: Add transaction support
await dynamoClient.putItem(config.tableNameAudits, AuditDto.toDynamoItem(audit));
await dynamoClient.putItem(config.tableNameLatestAudits, AuditDto.toDynamoItem(audit, true));
await dynamoClient.putItem(config.tableNameAudits, AuditDto.toDynamoItem(newAudit));
await dynamoClient.putItem(
config.tableNameLatestAudits,
AuditDto.toDynamoItem(newAudit, true),
);

return audit;
return newAudit;
};

/**
Expand Down
21 changes: 21 additions & 0 deletions packages/spacecat-shared-data-access/test/it/db.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,27 @@ describe('DynamoDB Integration Test', async () => {
expect(latestAudit.getSiteId()).to.equal(auditData.siteId);
expect(latestAudit.getAuditType()).to.equal(auditData.auditType);
expect(latestAudit.getAuditedAt()).to.equal(auditData.auditedAt);

const additionalAuditData = {
siteId: 'https://example1.com',
auditType: AUDIT_TYPE_LHS_MOBILE,
auditedAt: new Date().toISOString(),
isLive: true,
fullAuditRef: 's3://ref',
auditResult: {
scores: {
performance: 1,
seo: 1,
accessibility: 1,
'best-practices': 1,
},
},
};

const anotherAudit = await dataAccess.addAudit(additionalAuditData);

checkAudit(anotherAudit);
expect(anotherAudit.getPreviousAuditResult()).to.deep.equal(newAudit.getAuditResult());
});

it('throws an error when adding a duplicate audit', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ describe('Audit Model Tests', () => {
expect(() => createAudit({ ...validData, auditResult: 'not-an-object' })).to.throw('Audit result must be an object');
});

it('throws an error if previous audit result is not an object', () => {
expect(() => createAudit({ ...validData, previousAuditResult: 'not-an-object' }))
.to.throw('Previous audit result must be an object');
});

it('throws an error if previous audit result is missing scores property', () => {
expect(() => createAudit({ ...validData, previousAuditResult: {} }))
.to.throw('Missing scores property for audit type \'lhs-mobile\'');
});

it('throws an error if previous audit result is invalid', () => {
expect(() => createAudit({ ...validData, previousAuditResult: { scores: {} } }))
.to.throw('Missing expected property \'performance\' for audit type \'lhs-mobile\'');
});

it('throws an error if fullAuditRef is not provided', () => {
expect(() => createAudit({ ...validData, fullAuditRef: '' })).to.throw('Full audit ref must be provided');
});
Expand All @@ -62,6 +77,13 @@ describe('Audit Model Tests', () => {
expect(audit.getAuditType()).to.equal(validData.auditType.toLowerCase());
expect(audit.getAuditResult()).to.deep.equal(validData.auditResult);
expect(audit.getFullAuditRef()).to.equal(validData.fullAuditRef);
expect(audit.getPreviousAuditResult()).to.be.undefined;
});

it('throws an error when updating with invalid previous audit', () => {
const audit = createAudit(validData);

expect(() => audit.setPreviousAuditResult({})).to.throw('Missing scores property for audit type \'lhs-mobile\'');
});

it('automatically sets expiresAt if not provided', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ describe('Audit Access Pattern Tests', () => {
let exportedFunctions;

const auditData = {
siteId: 'siteId',
siteId: 'site1',
auditType: 'lhs-mobile',
auditedAt: new Date().toISOString(),
auditResult: {
Expand Down Expand Up @@ -204,6 +204,40 @@ describe('Audit Access Pattern Tests', () => {
expect(result.getAuditResult()).to.deep.equal(auditData.auditResult);
expect(result.getFullAuditRef()).to.equal(auditData.fullAuditRef);
expect(result.getScores()).to.be.an('object');
expect(result.getPreviousAuditResult()).to.be.undefined;
});

it('successfully adds a new audit with a previous audit result', async () => {
const auditResult = {
scores: {
performance: 0.2,
seo: 0.3,
accessibility: 0.4,
'best-practices': 0.5,
},
};
mockDynamoClient.getItem.withArgs(TEST_DA_CONFIG.tableNameLatestAudits, {
siteId: 'site1',
auditType: 'lhs-mobile',
}).resolves({ ...auditData, auditResult });

const result = await exportedFunctions.addAudit(auditData);

// Once for 'audits' and once for 'latest_audits'
expect(mockDynamoClient.putItem.calledTwice).to.be.true;
// Once for 'audits' and once for 'latest_audits'
expect(mockDynamoClient.getItem.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');
expect(result.getPreviousAuditResult()).to.be.an('object');
expect(result.getPreviousAuditResult().scores.performance).to.equal(0.2);
expect(result.getPreviousAuditResult().scores.seo).to.equal(0.3);
expect(result.getPreviousAuditResult().scores.accessibility).to.equal(0.4);
expect(result.getPreviousAuditResult().scores['best-practices']).to.equal(0.5);
});

it('successfully adds an error audit', async () => {
Expand Down