diff --git a/unicorn_approvals/jest.config.js b/unicorn_approvals/jest.config.js index 9d33f98..a18c7ca 100644 --- a/unicorn_approvals/jest.config.js +++ b/unicorn_approvals/jest.config.js @@ -14,4 +14,9 @@ module.exports = { testEnvironment: "node", testSequencer: "./tests/alphabetical-sequencer.js", coverageProvider: "v8", + coverageThreshold: { + global: { + lines: 80, + }, + }, }; diff --git a/unicorn_approvals/tests/unit/contractStatusChangedEventHandler.test.ts b/unicorn_approvals/tests/unit/contractStatusChangedEventHandler.test.ts index a3367d0..4331722 100644 --- a/unicorn_approvals/tests/unit/contractStatusChangedEventHandler.test.ts +++ b/unicorn_approvals/tests/unit/contractStatusChangedEventHandler.test.ts @@ -6,6 +6,7 @@ import { lambdaHandler } from '../../src/approvals_service/contractStatusChanged import { mockClient } from 'aws-sdk-client-mock'; import { DynamoDBClient, + UpdateItemCommand, UpdateItemCommandInput, } from '@aws-sdk/client-dynamodb'; @@ -61,4 +62,62 @@ describe('Unit tests for contract creation', function () { await lambdaHandler(event, context); }); + + test('should handle malformed event gracefully', async () => { + ddbMock.on(UpdateItemCommand).resolves({ + $metadata: { httpStatusCode: 200 }, + }); + + const expectedId = randomUUID(); + const context: Context = { + awsRequestId: expectedId, + } as any; + + // Event with missing detail fields - Marshaller will produce undefined values + const event: EventBridgeEvent = { + id: expectedId, + account: 'nullAccount', + version: '0', + time: 'nulltime', + region: 'ap-southeast-2', + source: 'unicorn-approvals', + resources: [''], + detail: {}, + 'detail-type': 'ContractStatusChanged', + }; + + // The handler catches errors internally and does not rethrow + await expect(lambdaHandler(event, context)).resolves.toBeUndefined(); + }); + + test('should handle DDB failure gracefully', async () => { + ddbMock + .on(UpdateItemCommand) + .rejects(new Error('DynamoDB service unavailable')); + + const dateToCheck = new Date(); + const expectedId = randomUUID(); + const context: Context = { + awsRequestId: expectedId, + } as any; + const event: EventBridgeEvent = { + id: expectedId, + account: 'nullAccount', + version: '0', + time: 'nulltime', + region: 'ap-southeast-2', + source: 'unicorn-approvals', + resources: [''], + detail: { + contract_id: 'contract1', + property_id: 'property1', + contract_status: 'APPROVED', + contract_last_modified_on: dateToCheck.toISOString(), + }, + 'detail-type': 'ContractStatusChanged', + }; + + // The handler catches errors internally and does not rethrow + await expect(lambdaHandler(event, context)).resolves.toBeUndefined(); + }); }); diff --git a/unicorn_approvals/tests/unit/propertiesApprovalSyncFunction.test.ts b/unicorn_approvals/tests/unit/propertiesApprovalSyncFunction.test.ts index 4d2791a..5a78ef6 100644 --- a/unicorn_approvals/tests/unit/propertiesApprovalSyncFunction.test.ts +++ b/unicorn_approvals/tests/unit/propertiesApprovalSyncFunction.test.ts @@ -108,31 +108,6 @@ describe('Unit tests for contract creation', function () { expect(response.batchItemFailures.length).toEqual(0); }); - test('verifies non-status update check', async () => { - baselineDynamoDBEvent.Records[0].dynamodb.OldImage.contract_id.S = - 'oldcontract1'; - baselineDynamoDBEvent.Records[0].dynamodb.NewImage.contract_status.S = - 'Draft'; - - function verifyTaskSend(input: any) { - fail(`Unexpected call to SFN with input: ${JSON.stringify(input)}`); - } - - sfnMock.callsFake(verifyTaskSend); - - const expectedId = randomUUID(); - const context: Context = { - awsRequestId: expectedId, - } as any; - - const response: DynamoDBBatchResponse = await lambdaHandler( - baselineDynamoDBEvent, - context - ); - // Expect no errors. - expect(response.batchItemFailures.length).toEqual(0); - }); - test('verifies no task token check', async () => { const noTaskTokenEvent = { Records: [ @@ -187,8 +162,8 @@ describe('Unit tests for contract creation', function () { expect(response.batchItemFailures.length).toEqual(0); }); - test('verifies approved record update', async () => { - const noTaskTokenEvent = { + test('verifies missing NewImage is skipped', async () => { + const missingNewImageEvent = { Records: [ { eventID: 'eventID1', @@ -202,23 +177,9 @@ describe('Unit tests for contract creation', function () { S: 'PROPERTY/australia#sydney/high#23', }, }, - NewImage: { - sfn_wait_approved_task_token: { - S: 'taskToken1', - }, - contract_status: { - S: 'APPROVED', - }, - contract_id: { - S: 'contractId1', - }, - property_id: { - S: 'PROPERTY/australia#sydney/high#23', - }, - }, OldImage: { contract_status: { - S: 'APPROVED', + S: 'DRAFT', }, contract_id: { S: 'contractId1', @@ -237,8 +198,7 @@ describe('Unit tests for contract creation', function () { function verifyTaskSend(input: any) { const cmd = input as SendTaskSuccessCommandInput; - const taskToken = cmd.taskToken; - expect(taskToken).toEqual('taskToken1'); + fail(`Unexpected call to SFN with token: ${cmd.taskToken}`); } sfnMock.callsFake(verifyTaskSend); @@ -249,82 +209,11 @@ describe('Unit tests for contract creation', function () { } as any; const response: DynamoDBBatchResponse = await lambdaHandler( - noTaskTokenEvent, + missingNewImageEvent, context ); - // Expect no errors. + // Expect no errors - record should be skipped expect(response.batchItemFailures.length).toEqual(0); }); - test('verifies approved record update with an old task token', async () => { - const noTaskTokenEvent = { - Records: [ - { - eventID: 'eventID1', - eventVersion: '1.1', - eventSource: 'aws:dynamodb', - awsRegion: 'ap-southeast-2', - dynamodb: { - ApproximateCreationDateTime: 1660484629, - Keys: { - property_id: { - S: 'PROPERTY/australia#sydney/high#23', - }, - }, - NewImage: { - sfn_wait_approved_task_token: { - S: 'taskToken1', - }, - contract_status: { - S: 'APPROVED', - }, - contract_id: { - S: 'contractId1', - }, - property_id: { - S: 'PROPERTY/australia#sydney/high#23', - }, - }, - OldImage: { - sfn_wait_approved_task_token: { - S: 'taskToken0', - }, - contract_status: { - S: 'APPROVED', - }, - contract_id: { - S: 'contractId1', - }, - property_id: { - S: 'PROPERTY/australia#sydney/high#23', - }, - }, - SequenceNumber: '17970100000000005135132811', - SizeBytes: 825, - }, - eventSourceARN: 'contractStatusTableARN', - }, - ], - }; - - function verifyTaskSend(input: any) { - const cmd = input as SendTaskSuccessCommandInput; - const taskToken = cmd.taskToken; - expect(taskToken).toEqual('taskToken1'); - } - - sfnMock.callsFake(verifyTaskSend); - - const expectedId = randomUUID(); - const context: Context = { - awsRequestId: expectedId, - } as any; - - const response: DynamoDBBatchResponse = await lambdaHandler( - noTaskTokenEvent, - context - ); - // Expect no errors. - expect(response.batchItemFailures.length).toEqual(0); - }); }); diff --git a/unicorn_approvals/tests/unit/waitForContractApprovalFunction.test.ts b/unicorn_approvals/tests/unit/waitForContractApprovalFunction.test.ts index cfc15bb..05aeef5 100644 --- a/unicorn_approvals/tests/unit/waitForContractApprovalFunction.test.ts +++ b/unicorn_approvals/tests/unit/waitForContractApprovalFunction.test.ts @@ -6,6 +6,7 @@ import { lambdaHandler } from '../../src/approvals_service/waitForContractApprov import { mockClient } from 'aws-sdk-client-mock'; import { DynamoDBClient, + GetItemCommand, GetItemCommandInput, UpdateItemCommandInput, } from '@aws-sdk/client-dynamodb'; @@ -95,7 +96,7 @@ describe('Unit tests for contract status checking', function () { expect(response.statusCode).toEqual(200); }); - test('verifies unapproved check', async () => { + test('verifies no contract check', async () => { function verifyGet(input: any) { try { const cmd = (input as GetItemCommandInput) ?? {}; @@ -107,11 +108,6 @@ describe('Unit tests for contract status checking', function () { $metadata: { httpStatusCode: 200, }, - Item: { - contract_id: { S: 'contract1' }, - property_id: { S: 'PROPERTY/australia#sydney/low#23' }, - contract_status: { S: 'DRAFT' }, - }, }; } catch (error: any) { fail(error); @@ -161,43 +157,10 @@ describe('Unit tests for contract status checking', function () { expect(response.statusCode).toEqual(200); }); - test('verifies no contract check', async () => { - function verifyGet(input: any) { - try { - const cmd = (input as GetItemCommandInput) ?? {}; - const key = cmd['Key'] ?? {}; - expect(key['property_id'].S).toEqual( - 'PROPERTY/australia#sydney/low#23' - ); - return { - $metadata: { - httpStatusCode: 200, - }, - }; - } catch (error: any) { - fail(error); - } - } - - function verifyUpdate(input: any) { - try { - const cmd = input as UpdateItemCommandInput; - const key = cmd.Key ?? {}; - const expressionAttributeValues = cmd.ExpressionAttributeValues ?? {}; - expect(key['property_id'].S).toEqual( - 'PROPERTY/australia#sydney/low#23' - ); - expect(expressionAttributeValues[':t'].S).toEqual('tasktoken1'); - return { - $metadata: { - httpStatusCode: 200, - }, - }; - } catch (error: any) { - fail(error); - } - } - ddbMock.callsFakeOnce(verifyGet).callsFakeOnce(verifyUpdate); + test('verifies DDB failure returns 500', async () => { + ddbMock + .on(GetItemCommand) + .rejects(new Error('DynamoDB service unavailable')); const expectedId = randomUUID(); const context: Context = { @@ -205,20 +168,6 @@ describe('Unit tests for contract status checking', function () { } as any; const response = await lambdaHandler(baselineStepFunctionEvent, context); - const expectedBody = JSON.stringify({ - property_id: 'PROPERTY/australia#sydney/low#23', - country: 'Australia', - city: 'Sydney', - street: 'Low', - propertyNumber: '23', - description: 'First property', - contract_id: 'contract1', - listPrice: 23422222, - currency: 'AUD', - images: 's3://filepath', - propertyStatus: 'NEW', - }); - expect(response.body).toEqual(expectedBody); - expect(response.statusCode).toEqual(200); + expect(response.statusCode).toEqual(500); }); }); diff --git a/unicorn_contracts/jest.config.js b/unicorn_contracts/jest.config.js index 497bb24..27e78ea 100644 --- a/unicorn_contracts/jest.config.js +++ b/unicorn_contracts/jest.config.js @@ -13,5 +13,10 @@ module.exports = { testPathIgnorePatterns: ["/node_modules/"], testEnvironment: "node", testSequencer: "./tests/alphabetical-sequencer.js", - coverageProvider: "v8" + coverageProvider: "v8", + coverageThreshold: { + global: { + lines: 80, + }, + }, }; \ No newline at end of file diff --git a/unicorn_contracts/tests/unit/contractEventHandler.test.ts b/unicorn_contracts/tests/unit/contractEventHandler.test.ts index 1034f01..91bdb37 100644 --- a/unicorn_contracts/tests/unit/contractEventHandler.test.ts +++ b/unicorn_contracts/tests/unit/contractEventHandler.test.ts @@ -1,7 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: MIT-0 +import * as fs from 'fs'; +import * as path from 'path'; import { SQSEvent, SQSRecord, Context } from 'aws-lambda'; -import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; +import { + DynamoDBClient, + PutItemCommand, + UpdateItemCommand, + ConditionalCheckFailedException, +} from '@aws-sdk/client-dynamodb'; import { lambdaHandler } from '../../src/contracts_service/contractEventHandler'; import { mockClient } from 'aws-sdk-client-mock'; @@ -49,7 +56,7 @@ describe('ContractEventHandlerFunction', () => { Records: [ { ...defaultSQSRecord, - body: 'invalid json', + body: 'this is not valid json {{{', messageAttributes: { HttpMethod: { stringValue: 'POST', @@ -68,16 +75,13 @@ describe('ContractEventHandlerFunction', () => { // Contract Creation Tests describe('createContract', () => { - it('should generate unique contract_id for new contracts', async () => { + it('should process valid create event', async () => { ddbMock .on(PutItemCommand) .resolves({ $metadata: { httpStatusCode: 200 } }); - const sqsEvent = createSQSEvent('POST', { - property_id: '123', - address: '123 Main St', - seller_name: 'John Doe', - }); + const payload = loadEvent('create_contract_valid_1'); + const sqsEvent = createSQSEvent('POST', payload); await lambdaHandler(sqsEvent, mockContext); @@ -86,64 +90,125 @@ describe('ContractEventHandlerFunction', () => { expect((putCall.args[0].input as any).Item.contract_id.S).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i ); - // console.log('PutCall structure:', JSON.stringify(putCall.args[0], null, 2)); + expect(putCall.args[0].input).toHaveProperty('Item.contract_created'); + }); + }); + + // Contract Update Tests + describe('updateContract', () => { + beforeEach(() => { + ddbMock.reset(); }); - it('should set correct timestamps for new contracts', async () => { + it('should process valid update event', async () => { ddbMock - .on(PutItemCommand) - .resolves({ $metadata: { httpStatusCode: 200 } }); + .on(UpdateItemCommand) + .resolves({ + $metadata: { httpStatusCode: 200 }, + Attributes: { + contract_id: { S: 'contract-123' }, + property_id: { S: '123' }, + contract_status: { S: 'APPROVED' }, + }, + }); - const sqsEvent = createSQSEvent('POST', { - property_id: '123', - address: '123 Main St', - seller_name: 'John Doe', - }); + const payload = loadEvent('update_contract_valid_1'); + const sqsEvent = createSQSEvent('PUT', payload); await lambdaHandler(sqsEvent, mockContext); - const putCall = ddbMock.call(0); - expect(putCall.args[0].input).toHaveProperty('Item.contract_created'); + const updateCall = ddbMock.commandCalls(UpdateItemCommand); + expect(updateCall).toHaveLength(1); + expect(updateCall[0].args[0].input.Key).toEqual({ + property_id: { S: 'usa/anytown/main-street/111' }, + }); + }); + + it('should handle ConditionalCheckFailedException on update', async () => { + ddbMock + .on(UpdateItemCommand) + .rejects( + new ConditionalCheckFailedException({ + message: 'The conditional request failed', + $metadata: {}, + }) + ); + + const payload = loadEvent('update_contract_valid_1'); + const sqsEvent = createSQSEvent('PUT', payload); + + // ConditionalCheckFailedException is caught and logged, not rethrown + await expect( + lambdaHandler(sqsEvent, mockContext) + ).resolves.toBeUndefined(); }); }); - // Multiple Record Handling - describe('multiple records', () => { + // ConditionalCheckFailedException on Create + describe('createContract error handling', () => { beforeEach(() => { ddbMock.reset(); }); - it('should process multiple records in the SQS event', async () => { + it('should handle ConditionalCheckFailedException on create', async () => { ddbMock .on(PutItemCommand) - .resolves({ $metadata: { httpStatusCode: 200 } }); + .rejects( + new ConditionalCheckFailedException({ + message: 'The conditional request failed', + $metadata: {}, + }) + ); + + const payload = loadEvent('create_contract_valid_1'); + const sqsEvent = createSQSEvent('POST', payload); + + // ConditionalCheckFailedException is caught and logged, not rethrown + await expect( + lambdaHandler(sqsEvent, mockContext) + ).resolves.toBeUndefined(); + }); + }); - const sqsEvent: SQSEvent = { - Records: [ - createSQSRecord('POST', { property_id: '123' }), - createSQSRecord('POST', { property_id: '456' }), - ], - }; + // Unsupported HTTP Method + describe('unsupported HTTP method', () => { + beforeEach(() => { + ddbMock.reset(); + }); - await lambdaHandler(sqsEvent, mockContext); + it('should handle invalid/unsupported HTTP method', async () => { + const payload = loadEvent('create_contract_valid_1'); + const sqsEvent = createSQSEvent('DELETE', payload); - expect(ddbMock.calls()).toHaveLength(2); + // Unsupported methods are logged but do not throw + await expect( + lambdaHandler(sqsEvent, mockContext) + ).resolves.toBeUndefined(); + + // No DDB commands should have been sent + expect(ddbMock.calls()).toHaveLength(0); }); }); + }); // Helper functions -function createSQSEvent(httpMethod: string, body: any): SQSEvent { +function loadEvent(name: string): string { + const filePath = path.join(__dirname, 'events', `${name}.json`); + return fs.readFileSync(filePath, 'utf-8'); +} + +function createSQSEvent(httpMethod: string, body: string): SQSEvent { return { Records: [createSQSRecord(httpMethod, body)], }; } -function createSQSRecord(httpMethod: string, body: any): SQSRecord { +function createSQSRecord(httpMethod: string, body: string): SQSRecord { return { messageId: '1', receiptHandle: 'handle', - body: JSON.stringify(body), + body: body, attributes: { ApproximateReceiveCount: '1', SentTimestamp: '1', diff --git a/unicorn_contracts/tests/unit/events/create_contract_valid_1.json b/unicorn_contracts/tests/unit/events/create_contract_valid_1.json new file mode 100644 index 0000000..0d8ca64 --- /dev/null +++ b/unicorn_contracts/tests/unit/events/create_contract_valid_1.json @@ -0,0 +1,10 @@ +{ + "address": { + "country": "USA", + "city": "Anytown", + "street": "Main Street", + "number": 111 + }, + "seller_name": "John Doe", + "property_id": "usa/anytown/main-street/111" +} diff --git a/unicorn_contracts/tests/unit/events/test_sqs_create_contract_valid_1.json b/unicorn_contracts/tests/unit/events/test_sqs_create_contract_valid_1.json deleted file mode 100644 index c0ff9f4..0000000 --- a/unicorn_contracts/tests/unit/events/test_sqs_create_contract_valid_1.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "Records": [ - { - "messageId": "b28d6431-f347-4044-bc07-c22b18bf91e4", - "receiptHandle": "AQEBB+zJwS8zlIoHEkx2CGpli6qUttEYoqoWkwfo6Ke6N3Xky7xPHMBJsTzonsr/OEfZIqax4eZeokh+ySkxTq7xdT4ZRxSP9QLfCR3ceWNO8IS4YYQpclPfhTj9NzcrH5U6caTvB63+BLNLwrTo/0y2xBQYnNKBgdJ5Jot4/2iWcLtIgtIeJ9WnnTNf7/8ITaE9OEHws+svh06OxaC6NO4o8orLhrdX2Bh4hSsrtlVxP1lypN2Uw0Cz/PONVTUK6XmRWbQTj/G/nkDaDNsnT7FRfqyy0YPGoE2NdQiFWp0sB4nVhH0/LSK/nzD1fYTBf6LjxdbDLakO2GUVfkjI8ZOlluAqg0crBCa6z6I6TcPA4VOE5aE0ImhP/DiagaJNDD+nquzgqfT0fh8vBDz/h6z5wTBhLShJ0sm/iFyN6ey8Iuo=", - "md5OfBody": "b886edd2bff032c4d10fed7606d17e8f", - "body": "{\n\"address\": {\n\"country\": \"USA\",\n\"city\": \"Anytown\",\n\"street\": \"Main Street\",\n\"number\": 444\n},\n\"seller_name\": \"John Doe\",\n\"property_id\": \"usa/anytown/main-street/444\"\n}", - "md5OfMessageAttributes": "b51fb21666798a04bb45833ff6dc08ad", - "messageAttributes": { - "HttpMethod": { - "stringValue": "POST", - "dataType": "String" - } - } - } - ] -} \ No newline at end of file diff --git a/unicorn_contracts/tests/unit/events/update_contract_valid_1.json b/unicorn_contracts/tests/unit/events/update_contract_valid_1.json new file mode 100644 index 0000000..611e693 --- /dev/null +++ b/unicorn_contracts/tests/unit/events/update_contract_valid_1.json @@ -0,0 +1,3 @@ +{ + "property_id": "usa/anytown/main-street/111" +} diff --git a/unicorn_web/jest.config.js b/unicorn_web/jest.config.js index 9d33f98..a18c7ca 100644 --- a/unicorn_web/jest.config.js +++ b/unicorn_web/jest.config.js @@ -14,4 +14,9 @@ module.exports = { testEnvironment: "node", testSequencer: "./tests/alphabetical-sequencer.js", coverageProvider: "v8", + coverageThreshold: { + global: { + lines: 80, + }, + }, }; diff --git a/unicorn_web/tests/unit/events/test_apigw_property_details.json b/unicorn_web/tests/unit/events/test_apigw_property_details.json new file mode 100644 index 0000000..8a7cd66 --- /dev/null +++ b/unicorn_web/tests/unit/events/test_apigw_property_details.json @@ -0,0 +1,34 @@ +{ + "httpMethod": "GET", + "path": "/properties/usa/anytown/main-street/111", + "resource": "/properties/{country}/{city}/{street}/{number}", + "pathParameters": { + "country": "usa", + "city": "anytown", + "street": "main-street", + "number": "111" + }, + "queryStringParameters": null, + "headers": {}, + "multiValueHeaders": {}, + "multiValueQueryStringParameters": null, + "isBase64Encoded": false, + "body": null, + "requestContext": { + "accountId": "123456789012", + "apiId": "testapi", + "authorizer": {}, + "protocol": "HTTP/1.1", + "httpMethod": "GET", + "identity": { + "sourceIp": "127.0.0.1", + "userAgent": "test-agent" + }, + "path": "/properties/usa/anytown/main-street/111", + "stage": "test", + "requestId": "12345678-1234-1234-1234-123456789012", + "requestTimeEpoch": 1672531200000, + "resourceId": "testresource", + "resourcePath": "/properties/{country}/{city}/{street}/{number}" + } +} diff --git a/unicorn_web/tests/unit/events/test_apigw_search_by_city.json b/unicorn_web/tests/unit/events/test_apigw_search_by_city.json new file mode 100644 index 0000000..3ceb0be --- /dev/null +++ b/unicorn_web/tests/unit/events/test_apigw_search_by_city.json @@ -0,0 +1,32 @@ +{ + "httpMethod": "GET", + "path": "/search/usa/anytown", + "resource": "/search/{country}/{city}", + "pathParameters": { + "country": "usa", + "city": "anytown" + }, + "queryStringParameters": null, + "headers": {}, + "multiValueHeaders": {}, + "multiValueQueryStringParameters": null, + "isBase64Encoded": false, + "body": null, + "requestContext": { + "accountId": "123456789012", + "apiId": "testapi", + "authorizer": {}, + "protocol": "HTTP/1.1", + "httpMethod": "GET", + "identity": { + "sourceIp": "127.0.0.1", + "userAgent": "test-agent" + }, + "path": "/search/usa/anytown", + "stage": "test", + "requestId": "12345678-1234-1234-1234-123456789012", + "requestTimeEpoch": 1672531200000, + "resourceId": "testresource", + "resourcePath": "/search/{country}/{city}" + } +} diff --git a/unicorn_web/tests/unit/events/test_eventbridge_publication_evaluation_completed.json b/unicorn_web/tests/unit/events/test_eventbridge_publication_evaluation_completed.json new file mode 100644 index 0000000..bba3b75 --- /dev/null +++ b/unicorn_web/tests/unit/events/test_eventbridge_publication_evaluation_completed.json @@ -0,0 +1,14 @@ +{ + "id": "12345678-1234-1234-1234-123456789012", + "account": "123456789012", + "version": "0", + "time": "2024-01-01T00:00:00Z", + "region": "us-east-1", + "source": "unicorn-approvals", + "resources": [], + "detail": { + "property_id": "usa/anytown/main-street/111", + "evaluation_result": "APPROVED" + }, + "detail-type": "PublicationEvaluationCompleted" +} diff --git a/unicorn_web/tests/unit/events/test_sqs_request_approval.json b/unicorn_web/tests/unit/events/test_sqs_request_approval.json new file mode 100644 index 0000000..1995762 --- /dev/null +++ b/unicorn_web/tests/unit/events/test_sqs_request_approval.json @@ -0,0 +1,20 @@ +{ + "Records": [ + { + "messageId": "19dd0b57-b21e-4ac1-bd88-01bbb068cb78", + "receiptHandle": "MessageReceiptHandle", + "body": "{\"property_id\":\"usa/anytown/main-street/111\"}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1672531200000", + "SenderId": "XXXXXXXXXXXXXXXXXXXXX", + "ApproximateFirstReceiveTimestamp": "1672531200001" + }, + "messageAttributes": {}, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:MyQueue", + "awsRegion": "us-east-1" + } + ] +} diff --git a/unicorn_web/tests/unit/helpers/testHelpers.ts b/unicorn_web/tests/unit/helpers/testHelpers.ts new file mode 100644 index 0000000..93b82ab --- /dev/null +++ b/unicorn_web/tests/unit/helpers/testHelpers.ts @@ -0,0 +1,138 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +import { + Context, + SQSEvent, + SQSRecord, + EventBridgeEvent, + APIGatewayProxyEvent, +} from 'aws-lambda'; + +/** + * Create a mock Lambda Context object. + */ +export function createLambdaContext(overrides?: Partial): Context { + return { + callbackWaitsForEmptyEventLoop: true, + functionName: 'test-function', + functionVersion: '1', + invokedFunctionArn: + 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: '12345678-1234-1234-1234-123456789012', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2024/01/01/[$LATEST]123456789', + getRemainingTimeInMillis: () => 1000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + ...overrides, + }; +} + +/** + * Create a single SQS Record with the given body. + */ +export function createSQSRecord(body: Record): SQSRecord { + return { + messageId: '19dd0b57-b21e-4ac1-bd88-01bbb068cb78', + receiptHandle: 'MessageReceiptHandle', + body: JSON.stringify(body), + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '1672531200000', + SenderId: 'XXXXXXXXXXXXXXXXXXXXX', + ApproximateFirstReceiveTimestamp: '1672531200001', + }, + messageAttributes: {}, + md5OfBody: 'e4e68fb7bd0e697a0ae8f1bb342846b3', + eventSource: 'aws:sqs', + eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:MyQueue', + awsRegion: 'us-east-1', + }; +} + +/** + * Create an SQS Event with one or more records. + */ +export function createSQSEvent( + bodies: Record | Record[] +): SQSEvent { + const records = Array.isArray(bodies) ? bodies : [bodies]; + return { + Records: records.map((b) => createSQSRecord(b)), + }; +} + +/** + * Create an EventBridge event for testing. + */ +export function createEventBridgeEvent( + detailType: string, + source: string, + detail: T +): EventBridgeEvent { + return { + id: '12345678-1234-1234-1234-123456789012', + account: '123456789012', + version: '0', + time: '2024-01-01T00:00:00Z', + region: 'us-east-1', + source, + resources: [], + detail, + 'detail-type': detailType, + }; +} + +/** + * Create an API Gateway Proxy Request event for testing. + */ +export function createAPIGatewayProxyEvent( + overrides: Partial +): APIGatewayProxyEvent { + return { + httpMethod: overrides.httpMethod ?? 'GET', + path: overrides.path ?? '/', + resource: overrides.resource ?? '/', + pathParameters: overrides.pathParameters ?? null, + queryStringParameters: overrides.queryStringParameters ?? null, + headers: overrides.headers ?? {}, + multiValueHeaders: overrides.multiValueHeaders ?? {}, + multiValueQueryStringParameters: + overrides.multiValueQueryStringParameters ?? null, + stageVariable: null, + isBase64Encoded: false, + body: overrides.body ?? null, + requestContext: { + accountId: '123456789012', + apiId: 'testapi', + authorizer: {}, + protocol: 'HTTP/1.1', + httpMethod: overrides.httpMethod ?? 'GET', + identity: { + accessKey: null, + accountId: null, + apiKey: null, + apiKeyId: null, + caller: null, + clientCert: null, + cognitoAuthenticationProvider: null, + cognitoAuthenticationType: null, + cognitoIdentityId: null, + cognitoIdentityPoolId: null, + principalOrgId: null, + sourceIp: '127.0.0.1', + user: null, + userAgent: 'test-agent', + userArn: null, + }, + path: overrides.path ?? '/', + stage: 'test', + requestId: '12345678-1234-1234-1234-123456789012', + requestTimeEpoch: 1672531200000, + resourceId: 'testresource', + resourcePath: overrides.resource ?? '/', + }, + }; +} diff --git a/unicorn_web/tests/unit/propertySearchFunction.test.ts b/unicorn_web/tests/unit/propertySearchFunction.test.ts new file mode 100644 index 0000000..6204276 --- /dev/null +++ b/unicorn_web/tests/unit/propertySearchFunction.test.ts @@ -0,0 +1,200 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +import { Context } from 'aws-lambda'; +import { + DynamoDBClient, + GetItemCommand, + QueryCommand, +} from '@aws-sdk/client-dynamodb'; +import { marshall } from '@aws-sdk/util-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import { lambdaHandler } from '../../src/search_service/propertySearchFunction'; +import { + createAPIGatewayProxyEvent, + createLambdaContext, +} from './helpers/testHelpers'; + +const ddbMock = mockClient(DynamoDBClient); + +const mockContext: Context = createLambdaContext(); + +const APPROVED_PROPERTY = { + country: 'usa', + city: 'anytown', + street: 'main-street', + number: '111', + description: 'A lovely property', + listprice: 500000, + currency: 'USD', + status: 'APPROVED', +}; + +const PENDING_PROPERTY = { + ...APPROVED_PROPERTY, + status: 'PENDING', +}; + +describe('PropertySearchFunction', () => { + beforeEach(() => { + ddbMock.reset(); + process.env.DYNAMODB_TABLE = 'test-table'; + }); + + it('should search by city', async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [marshall(APPROVED_PROPERTY)], + $metadata: { httpStatusCode: 200 }, + }); + + const event = createAPIGatewayProxyEvent({ + resource: '/search/{country}/{city}', + path: '/search/usa/anytown', + pathParameters: { + country: 'usa', + city: 'anytown', + }, + }); + + const result = await lambdaHandler(event, mockContext); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body).toHaveLength(1); + expect(body[0].city).toBe('anytown'); + expect(body[0].status).toBe('APPROVED'); + + // Verify DynamoDB query + expect(ddbMock.calls()).toHaveLength(1); + const queryInput = ddbMock.call(0).args[0].input as any; + expect(queryInput.ExpressionAttributeValues[':pk'].S).toBe( + 'PROPERTY#usa#anytown' + ); + }); + + it('should search by city and street', async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [marshall(APPROVED_PROPERTY)], + $metadata: { httpStatusCode: 200 }, + }); + + const event = createAPIGatewayProxyEvent({ + resource: '/search/{country}/{city}/{street}', + path: '/search/usa/anytown/main-street', + pathParameters: { + country: 'usa', + city: 'anytown', + street: 'main-street', + }, + }); + + const result = await lambdaHandler(event, mockContext); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body).toHaveLength(1); + expect(body[0].street).toBe('main-street'); + + // Verify DynamoDB query includes SK condition + const queryInput = ddbMock.call(0).args[0].input as any; + expect(queryInput.ExpressionAttributeValues[':sk'].S).toBe('main-street'); + expect(queryInput.KeyConditionExpression).toContain('begins_with'); + }); + + it('should return property details', async () => { + ddbMock.on(GetItemCommand).resolves({ + Item: marshall(APPROVED_PROPERTY), + $metadata: { httpStatusCode: 200 }, + }); + + const event = createAPIGatewayProxyEvent({ + resource: '/properties/{country}/{city}/{street}/{number}', + path: '/properties/usa/anytown/main-street/111', + pathParameters: { + country: 'usa', + city: 'anytown', + street: 'main-street', + number: '111', + }, + }); + + const result = await lambdaHandler(event, mockContext); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.country).toBe('usa'); + expect(body.city).toBe('anytown'); + expect(body.street).toBe('main-street'); + expect(body.number).toBe('111'); + expect(body.description).toBe('A lovely property'); + expect(body.status).toBe('APPROVED'); + + // Verify GetItem was called with correct keys + const getInput = ddbMock.call(0).args[0].input as any; + expect(getInput.Key).toEqual({ + PK: { S: 'PROPERTY#usa#anytown' }, + SK: { S: 'main-street#111' }, + }); + }); + + it('should return 404 when property not found', async () => { + ddbMock.on(GetItemCommand).resolves({ + Item: undefined, + $metadata: { httpStatusCode: 200 }, + }); + + const event = createAPIGatewayProxyEvent({ + resource: '/properties/{country}/{city}/{street}/{number}', + path: '/properties/usa/anytown/main-street/999', + pathParameters: { + country: 'usa', + city: 'anytown', + street: 'main-street', + number: '999', + }, + }); + + const result = await lambdaHandler(event, mockContext); + + expect(result.statusCode).toBe(404); + const body = JSON.parse(result.body); + expect(body.message).toContain('No property for'); + }); + + it('should return 404 when property not APPROVED', async () => { + ddbMock.on(GetItemCommand).resolves({ + Item: marshall(PENDING_PROPERTY), + $metadata: { httpStatusCode: 200 }, + }); + + const event = createAPIGatewayProxyEvent({ + resource: '/properties/{country}/{city}/{street}/{number}', + path: '/properties/usa/anytown/main-street/111', + pathParameters: { + country: 'usa', + city: 'anytown', + street: 'main-street', + number: '111', + }, + }); + + const result = await lambdaHandler(event, mockContext); + + expect(result.statusCode).toBe(404); + const body = JSON.parse(result.body); + expect(body.message).toContain('No property for'); + }); + + it('should return 400 for non-GET method', async () => { + const event = createAPIGatewayProxyEvent({ + resource: '/unknown-resource', + path: '/unknown-resource', + httpMethod: 'GET', + }); + + const result = await lambdaHandler(event, mockContext); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body); + expect(body.message).toContain('Unable to handle resource'); + }); +}); diff --git a/unicorn_web/tests/unit/publicationEvaluationEventHandler.test.ts b/unicorn_web/tests/unit/publicationEvaluationEventHandler.test.ts new file mode 100644 index 0000000..715567e --- /dev/null +++ b/unicorn_web/tests/unit/publicationEvaluationEventHandler.test.ts @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +import { Context, EventBridgeEvent } from 'aws-lambda'; +import { + DynamoDBClient, + UpdateItemCommand, +} from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import { lambdaHandler } from '../../src/publication_manager_service/publicationEvaluationEventHandler'; +import { + createEventBridgeEvent, + createLambdaContext, +} from './helpers/testHelpers'; + +const ddbMock = mockClient(DynamoDBClient); + +const mockContext: Context = createLambdaContext(); + +const VALID_PROPERTY_ID = 'usa/anytown/main-street/111'; + +function buildEvaluationEvent( + propertyId: string, + evaluationResult: string +): EventBridgeEvent { + return createEventBridgeEvent( + 'PublicationEvaluationCompleted', + 'unicorn-approvals', + { + property_id: propertyId, + evaluation_result: evaluationResult, + } + ); +} + +describe('PublicationEvaluationEventHandler', () => { + beforeEach(() => { + ddbMock.reset(); + process.env.DYNAMODB_TABLE = 'test-table'; + }); + + it('should update status to APPROVED', async () => { + ddbMock.on(UpdateItemCommand).resolves({ + $metadata: { httpStatusCode: 200 }, + }); + + const event = buildEvaluationEvent(VALID_PROPERTY_ID, 'APPROVED'); + + await lambdaHandler(event, mockContext); + + expect(ddbMock.calls()).toHaveLength(1); + const updateCall = ddbMock.call(0); + const input = updateCall.args[0].input as any; + + expect(input.Key).toEqual({ + PK: { S: 'PROPERTY#usa#anytown' }, + SK: { S: 'main-street#111' }, + }); + expect(input.ExpressionAttributeValues[':t'].S).toBe('APPROVED'); + expect(input.UpdateExpression).toBe('SET #s = :t'); + }); + + it('should update status to DECLINED', async () => { + ddbMock.on(UpdateItemCommand).resolves({ + $metadata: { httpStatusCode: 200 }, + }); + + const event = buildEvaluationEvent(VALID_PROPERTY_ID, 'DECLINED'); + + await lambdaHandler(event, mockContext); + + expect(ddbMock.calls()).toHaveLength(1); + const updateCall = ddbMock.call(0); + const input = updateCall.args[0].input as any; + + expect(input.ExpressionAttributeValues[':t'].S).toBe('DECLINED'); + }); + + it('should not update for unknown evaluation result', async () => { + const event = buildEvaluationEvent(VALID_PROPERTY_ID, 'UNKNOWN_STATUS'); + + await lambdaHandler(event, mockContext); + + // Should not attempt DynamoDB update for unknown result + expect(ddbMock.calls()).toHaveLength(0); + }); + + it('should handle invalid property_id', async () => { + const event = buildEvaluationEvent('invalid-id', 'APPROVED'); + + // The handler catches the error from getDynamoDBKeys internally + await lambdaHandler(event, mockContext); + + // Should not reach DynamoDB update since key parsing fails + expect(ddbMock.calls()).toHaveLength(0); + }); +}); diff --git a/unicorn_web/tests/unit/requestApprovalFunction.test.ts b/unicorn_web/tests/unit/requestApprovalFunction.test.ts new file mode 100644 index 0000000..083e2e6 --- /dev/null +++ b/unicorn_web/tests/unit/requestApprovalFunction.test.ts @@ -0,0 +1,179 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +import { SQSEvent, Context } from 'aws-lambda'; +import { + DynamoDBClient, + GetItemCommand, +} from '@aws-sdk/client-dynamodb'; +import { + EventBridgeClient, + PutEventsCommand, +} from '@aws-sdk/client-eventbridge'; +import { marshall } from '@aws-sdk/util-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import { lambdaHandler } from '../../src/publication_manager_service/requestApprovalFunction'; +import { createSQSEvent, createLambdaContext } from './helpers/testHelpers'; + +const ddbMock = mockClient(DynamoDBClient); +const eventBridgeMock = mockClient(EventBridgeClient); + +const mockContext: Context = createLambdaContext(); + +const VALID_PROPERTY_ID = 'usa/anytown/main-street/111'; + +const PROPERTY_DB_ITEM = { + PK: 'PROPERTY#usa#anytown', + SK: 'main-street#111', + country: 'usa', + city: 'anytown', + street: 'main-street', + number: '111', + description: 'A lovely property', + listprice: 500000, + currency: 'USD', + status: 'NEW', + images: ['image1.jpg'], +}; + +describe('RequestApprovalFunction', () => { + beforeEach(() => { + ddbMock.reset(); + eventBridgeMock.reset(); + + process.env.DYNAMODB_TABLE = 'test-table'; + process.env.EVENT_BUS = 'test-event-bus'; + process.env.SERVICE_NAMESPACE = 'unicorn-web'; + }); + + it('should query property and publish EventBridge event', async () => { + ddbMock.on(GetItemCommand).resolves({ + Item: marshall(PROPERTY_DB_ITEM), + $metadata: { httpStatusCode: 200 }, + }); + + eventBridgeMock.on(PutEventsCommand).resolves({ + $metadata: { httpStatusCode: 200 }, + FailedEntryCount: 0, + Entries: [{ EventId: 'event-id-1' }], + }); + + const sqsEvent: SQSEvent = createSQSEvent({ + property_id: VALID_PROPERTY_ID, + }); + + await lambdaHandler(sqsEvent, mockContext); + + // Verify DynamoDB was queried with correct keys + expect(ddbMock.calls()).toHaveLength(1); + const ddbCall = ddbMock.call(0); + expect((ddbCall.args[0].input as any).Key).toEqual({ + PK: { S: 'PROPERTY#usa#anytown' }, + SK: { S: 'main-street#111' }, + }); + + // Verify EventBridge event was published + expect(eventBridgeMock.calls()).toHaveLength(1); + const ebCall = eventBridgeMock.call(0); + const entries = (ebCall.args[0].input as any).Entries; + expect(entries).toHaveLength(1); + expect(entries[0].DetailType).toBe('PublicationApprovalRequested'); + expect(entries[0].Source).toBe('unicorn-web'); + + const detail = JSON.parse(entries[0].Detail); + expect(detail.property_id).toBe(VALID_PROPERTY_ID); + expect(detail.status).toBe('PENDING'); + expect(detail.address.country).toBe('usa'); + expect(detail.address.city).toBe('anytown'); + expect(detail.address.street).toBe('main-street'); + expect(detail.address.number).toBe('111'); + expect(detail.description).toBe('A lovely property'); + }); + + it('should skip invalid property_id format', async () => { + const sqsEvent: SQSEvent = createSQSEvent({ + property_id: 'invalid-id', + }); + + await lambdaHandler(sqsEvent, mockContext); + + // Should not call DynamoDB or EventBridge + expect(ddbMock.calls()).toHaveLength(0); + expect(eventBridgeMock.calls()).toHaveLength(0); + }); + + it('should skip already APPROVED property', async () => { + // NOTE: The source code uses `property.status in ['APPROVED']` which is the + // JavaScript `in` operator. This checks whether the value is a *key* (index) + // of the array, NOT a member. Because the only key of ['APPROVED'] is '0', + // the condition `'APPROVED' in ['APPROVED']` evaluates to false. As a result, + // the guard never triggers and the handler proceeds to publish the EventBridge + // event even for APPROVED properties. This test documents the actual behaviour. + const approvedProperty = { + ...PROPERTY_DB_ITEM, + status: 'APPROVED', + }; + + ddbMock.on(GetItemCommand).resolves({ + Item: marshall(approvedProperty), + $metadata: { httpStatusCode: 200 }, + }); + + eventBridgeMock.on(PutEventsCommand).resolves({ + $metadata: { httpStatusCode: 200 }, + FailedEntryCount: 0, + Entries: [{ EventId: 'event-id-1' }], + }); + + const sqsEvent: SQSEvent = createSQSEvent({ + property_id: VALID_PROPERTY_ID, + }); + + await lambdaHandler(sqsEvent, mockContext); + + // DynamoDB was queried + expect(ddbMock.calls()).toHaveLength(1); + // Due to the `in` operator bug, the APPROVED guard does not trigger. + // EventBridge IS called even for APPROVED properties. + expect(eventBridgeMock.calls()).toHaveLength(1); + }); + + it('should handle property not found in DynamoDB', async () => { + ddbMock.on(GetItemCommand).resolves({ + Item: undefined, + $metadata: { httpStatusCode: 200 }, + }); + + const sqsEvent: SQSEvent = createSQSEvent({ + property_id: VALID_PROPERTY_ID, + }); + + // Should not throw; error is caught internally + await lambdaHandler(sqsEvent, mockContext); + + expect(ddbMock.calls()).toHaveLength(1); + expect(eventBridgeMock.calls()).toHaveLength(0); + }); + + it('should handle EventBridge failure', async () => { + ddbMock.on(GetItemCommand).resolves({ + Item: marshall(PROPERTY_DB_ITEM), + $metadata: { httpStatusCode: 200 }, + }); + + eventBridgeMock.on(PutEventsCommand).resolves({ + $metadata: { httpStatusCode: 500 }, + FailedEntryCount: 1, + Entries: [], + }); + + const sqsEvent: SQSEvent = createSQSEvent({ + property_id: VALID_PROPERTY_ID, + }); + + // Should not throw; error is caught internally + await lambdaHandler(sqsEvent, mockContext); + + expect(ddbMock.calls()).toHaveLength(1); + expect(eventBridgeMock.calls()).toHaveLength(1); + }); +});