|
| 1 | +import { Readable, Writable } from 'stream' |
| 2 | +import { PutItemInput } from 'aws-sdk/clients/dynamodb' |
| 3 | +import { GetObjectRequest } from 'aws-sdk/clients/s3' |
| 4 | +import s3Event from '../../parse-covid-csv/events/event.json' |
| 5 | + |
| 6 | +interface IMockGetObject { |
| 7 | + createReadStream: jest.Mock |
| 8 | +} |
| 9 | + |
| 10 | +// This file represents common unit tests we see in projects. |
| 11 | +// These are not the best testing examples, you'll see the better approach |
| 12 | +// using hexagonal architecture in the following commits. |
| 13 | +describe('Parse Covid CSV', () => { |
| 14 | + // We need to be able to check if these mocks are invoked in multiple tests, |
| 15 | + // so we define them outside of the beforeEach block |
| 16 | + const mockedS3GetObject = jest.fn() |
| 17 | + const mockedDynamoDBPut = jest.fn() |
| 18 | + const lambdaPath = '../../parse-covid-csv/lambda' |
| 19 | + |
| 20 | + // Before each test, we'll create mocks of required libraries and modules. |
| 21 | + // We can do this before all tests, but as we are using Node streams, |
| 22 | + // we can't write to the stream that is already closed, |
| 23 | + // so we'll re-create our mocks before each run. |
| 24 | + // This is not an optimal solution, but it works. |
| 25 | + beforeEach(function () { |
| 26 | + // We can try to use the aws-sdk-mock library, but we need to mock fs too. |
| 27 | + // It's easier to mock everything using the built-in Jest functionality. |
| 28 | + // We are mocking the "clients/s3" path of the "aws-sdk" module, |
| 29 | + // because our app imports this path |
| 30 | + jest.mock('aws-sdk/clients/s3', () => { |
| 31 | + // Instead of a real class, we'll return a mock class here |
| 32 | + return class S3 { |
| 33 | + // We only use the "getObject" method, so we mock that one only. |
| 34 | + // We do not care about other methods. |
| 35 | + // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| 36 | + getObject(_params: GetObjectRequest, _cb: () => {}): IMockGetObject { |
| 37 | + return { |
| 38 | + // Instead of a built-in "createReadStream" method, we'll return a simple value in our mock |
| 39 | + createReadStream: mockedS3GetObject.mockReturnValue(Readable.from(['hello,name\n1,2\n'])), |
| 40 | + } |
| 41 | + } |
| 42 | + } |
| 43 | + }) |
| 44 | + |
| 45 | + // Similar to S3, we also need to mock DynamoDB DocumentClient |
| 46 | + jest.mock('aws-sdk/clients/dynamodb', () => { |
| 47 | + return { DocumentClient: class DocumentClient { |
| 48 | + // We use only the "put" method of the DynamoDB DocumentClient |
| 49 | + // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| 50 | + put(params: PutItemInput, cb: () => {}): jest.Mock { |
| 51 | + // The DynamoDB put method returns a promise function. |
| 52 | + // We'll simply return resolved promise from our mock, |
| 53 | + // as that's enough for our current unit tests |
| 54 | + return (mockedDynamoDBPut.mockReturnValue({ |
| 55 | + promise: () => Promise.resolve(), |
| 56 | + }))(params, cb) // We also need to pass the arguments to our mock function |
| 57 | + } |
| 58 | + }} |
| 59 | + }) |
| 60 | + |
| 61 | + // We also need to mock the uuid module, because it returns random values, which makes it hard to test. |
| 62 | + // Other option would be to expect any string as an ID generated by the uuid module. |
| 63 | + jest.mock('uuid', () => { |
| 64 | + return { |
| 65 | + v4: jest.fn().mockReturnValue('123'), |
| 66 | + } |
| 67 | + }) |
| 68 | + }) |
| 69 | + |
| 70 | + // After each test, we reset all mocks and reset the cache for all required node modules |
| 71 | + afterEach(() => { |
| 72 | + jest.resetAllMocks() |
| 73 | + jest.resetModules() |
| 74 | + }) |
| 75 | + |
| 76 | + test('should parse a single CSV file and store the row data into a database', async () => { |
| 77 | + // As we are using streams, we need to mock the fs module too. |
| 78 | + // We cand do this in the beforeEach section, but that creates some problems with writing to the finished writable stream. |
| 79 | + // Also, we need to modify the fs.createReadStream function for almost all tests, and it's easier to do that in the test itself. |
| 80 | + jest.mock('fs', () => { |
| 81 | + return { |
| 82 | + // The fs.createReadStream function returns a read stream with the test CSV data |
| 83 | + createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name\n1,2\n'])), |
| 84 | + // The fs.createWriteStream function returns an empty writabe stream, so we can write to it |
| 85 | + createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })), |
| 86 | + } |
| 87 | + }) |
| 88 | + // We require and invoke our hadnler function. |
| 89 | + // Moving this function to the top of this file would break the mocks from the beforeEach section. |
| 90 | + const handler = require(lambdaPath).handler |
| 91 | + await handler(s3Event) |
| 92 | + // Then we test that all of our important mocks have been called.s |
| 93 | + expect(mockedS3GetObject).toHaveBeenCalled() |
| 94 | + expect(mockedDynamoDBPut).toHaveBeenCalled() |
| 95 | + expect(mockedDynamoDBPut).toHaveBeenCalledTimes(1) |
| 96 | + expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '1', name: '2', id: '123' }, TableName: ''}, undefined) |
| 97 | + }) |
| 98 | + |
| 99 | + test('should parse a single CSV file with multiple rows and store the rows data into a database', async () => { |
| 100 | + jest.mock('fs', () => { |
| 101 | + return { |
| 102 | + // We simply return a different CSV file content here |
| 103 | + createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name\n1,1\n2,2\n'])), |
| 104 | + createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })), |
| 105 | + } |
| 106 | + }) |
| 107 | + const handler = require(lambdaPath).handler |
| 108 | + await handler(s3Event) |
| 109 | + expect(mockedS3GetObject).toHaveBeenCalled() |
| 110 | + expect(mockedDynamoDBPut).toHaveBeenCalled() |
| 111 | + // And we test that both rows were saved to the DynamoDB table |
| 112 | + expect(mockedDynamoDBPut).toHaveBeenCalledTimes(2) |
| 113 | + // We test the arguments for the first invocation of our mockedDynamoDBPut |
| 114 | + expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '1', name: '1', id: '123' }, TableName: '' }, undefined) |
| 115 | + // Then we do the same for the next one |
| 116 | + expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '2', name: '2', id: '123' }, TableName: '' }, undefined) |
| 117 | + }) |
| 118 | + |
| 119 | + test('should parse a single empty CSV file, without storing the CSV data into the database', async () => { |
| 120 | + jest.mock('fs', () => { |
| 121 | + return { |
| 122 | + // We send the empty CSV file in our fs.createReadStream mock |
| 123 | + createReadStream: jest.fn().mockImplementation(() => Readable.from([''])), |
| 124 | + createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })), |
| 125 | + } |
| 126 | + }) |
| 127 | + const handler = require(lambdaPath).handler |
| 128 | + await handler(s3Event) |
| 129 | + expect(mockedS3GetObject).toHaveBeenCalled() |
| 130 | + expect(mockedDynamoDBPut).not.toHaveBeenCalled() |
| 131 | + }) |
| 132 | + |
| 133 | + test('should parse a single CSV file, with less row values than headers and store the ones properly parsed into the database', async () => { |
| 134 | + jest.mock('fs', () => { |
| 135 | + return { |
| 136 | + // We send less row values than headers in a CSV file in our fs.createReadStream mock |
| 137 | + createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name,ignored,headers\n1,2\n'])), |
| 138 | + createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })), |
| 139 | + } |
| 140 | + }) |
| 141 | + const handler = require(lambdaPath).handler |
| 142 | + await handler(s3Event) |
| 143 | + expect(mockedS3GetObject).toHaveBeenCalled() |
| 144 | + expect(mockedDynamoDBPut).toHaveBeenCalled() |
| 145 | + expect(mockedDynamoDBPut).toHaveBeenCalledTimes(1) |
| 146 | + expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '1', name: '2', id: '123' }, TableName: '' }, undefined) |
| 147 | + }) |
| 148 | + |
| 149 | + test('should parse multiple CSV files and store a JSON string', async () => { |
| 150 | + jest.mock('fs', () => { |
| 151 | + return { |
| 152 | + // We prepare a single row response from our |
| 153 | + createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name,\n1,2\n'])), |
| 154 | + createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })), |
| 155 | + } |
| 156 | + }) |
| 157 | + // Copy the S3 event, because we don't want to to reference and edit the existing one |
| 158 | + const multipleFilesS3Event = Object.assign({}, s3Event) |
| 159 | + // Copy the single S3 event record, and then push multiple values of it |
| 160 | + // Don't worry, it will store them differently, because we are generating a different id for each CSV row |
| 161 | + const recordCopy = Object.assign({}, s3Event.Records[0]) |
| 162 | + multipleFilesS3Event.Records.push(recordCopy) |
| 163 | + multipleFilesS3Event.Records.push(recordCopy) |
| 164 | + const handler = require(lambdaPath).handler |
| 165 | + await handler(multipleFilesS3Event) |
| 166 | + // Since there are three S3 files, the S3 getObject method needs to be called three times, to retrieve three documents |
| 167 | + expect(mockedS3GetObject).toHaveBeenCalledTimes(3) |
| 168 | + // Since there are three S3 files with a single row each, the DynamoDb put method needs to be called three times, to store the three rows |
| 169 | + expect(mockedDynamoDBPut).toHaveBeenCalledTimes(3) |
| 170 | + expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '1', name: '2', id: '123' }, TableName: '' }, undefined) |
| 171 | + }) |
| 172 | + |
| 173 | + test('should throw an error when the CSV file is unavailable from the bucket', async () => { |
| 174 | + // We define the error message for the S3 getObject operation, NoSuchKey Eception |
| 175 | + const fileUnavailableError = 'An error occurred (NoSuchKey) when calling the GetObject operation: The specified key does not exist.' |
| 176 | + jest.mock('fs', () => { |
| 177 | + return { |
| 178 | + createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name\n1,2\n'])), |
| 179 | + createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })), |
| 180 | + } |
| 181 | + }) |
| 182 | + jest.mock('aws-sdk/clients/s3', () => { |
| 183 | + // Instead of a real class, we'll return a mock class here |
| 184 | + return class S3 { |
| 185 | + // We need to setup the "getObject" method to throw the defined Error when trying to stream |
| 186 | + // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| 187 | + getObject(_params: GetObjectRequest, _cb: () => {}): IMockGetObject { |
| 188 | + return { |
| 189 | + createReadStream: mockedS3GetObject.mockImplementation(() => { throw new Error(fileUnavailableError) }), |
| 190 | + } |
| 191 | + } |
| 192 | + } |
| 193 | + }) |
| 194 | + |
| 195 | + const handler = require(lambdaPath).handler |
| 196 | + await expect(handler(s3Event)).rejects.toEqual(new Error(fileUnavailableError)) |
| 197 | + expect(mockedS3GetObject).toHaveBeenCalled() |
| 198 | + expect(mockedDynamoDBPut).not.toHaveBeenCalled() |
| 199 | + }) |
| 200 | + |
| 201 | + test('should throw an error if the parsed CSV data could not be stored into the database', async () => { |
| 202 | + // We define the error message for the DynamoDB operation, Access Denied Exception is a standard one |
| 203 | + // eslint-disable-next-line max-len |
| 204 | + const accessDeniedException = 'AccessDeniedException: User: arn:aws:sts::111111111:assumed-role/lambdaRole/username is not authorized to perform: dynamodb:PutItem on resource: arn:aws:dynamodb:us-east-1:111111111:table/covid19-cases/index/type-created_at-index' |
| 205 | + jest.mock('fs', () => { |
| 206 | + return { |
| 207 | + createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name\n1,2\n'])), |
| 208 | + createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })), |
| 209 | + } |
| 210 | + }) |
| 211 | + // We also need to mock DynamoDB DocumentClient to properly throw this error |
| 212 | + jest.mock('aws-sdk/clients/dynamodb', () => { |
| 213 | + return { DocumentClient: class DocumentClient { |
| 214 | + // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| 215 | + put(params: PutItemInput, cb: () => {}): jest.Mock { |
| 216 | + // The DynamoDB put method returns a promise function. |
| 217 | + // We'll return a rejected promise from our mock, with an error message |
| 218 | + return (mockedDynamoDBPut.mockReturnValue({ |
| 219 | + promise: () => Promise.reject(new Error(accessDeniedException)), |
| 220 | + }))(params, cb) |
| 221 | + } |
| 222 | + }} |
| 223 | + }) |
| 224 | + const handler = require(lambdaPath).handler |
| 225 | + |
| 226 | + await expect(handler(s3Event)).rejects.toEqual(new Error(accessDeniedException)) |
| 227 | + expect(mockedS3GetObject).toHaveBeenCalled() |
| 228 | + expect(mockedDynamoDBPut).toHaveBeenCalled() |
| 229 | + }) |
| 230 | +}) |
0 commit comments