├── .eslintrc.js ├── .gitattributes ├── .github ├── .kodiak.toml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release-please.yml │ └── test.yml ├── .gitignore ├── .husky └── commit-msg ├── .npmignore ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── chai │ ├── README.md │ ├── api.test.ts │ ├── api.ts │ ├── cloudwatch.test.ts │ ├── cloudwatch.ts │ ├── dynamoDb.test.ts │ ├── dynamoDb.ts │ ├── index.test.ts │ ├── index.ts │ ├── kinesis.test.ts │ ├── kinesis.ts │ ├── s3.test.ts │ ├── s3.ts │ ├── sqs.test.ts │ ├── sqs.ts │ ├── stepFunctions.test.ts │ ├── stepFunctions.ts │ ├── utils.test.ts │ └── utils.ts ├── common │ ├── api.ts │ ├── cloudwatch.ts │ ├── dynamoDb.test.ts │ ├── dynamoDb.ts │ ├── index.test.ts │ ├── index.ts │ ├── kinesis.ts │ ├── s3.ts │ ├── sqs.ts │ └── stepFunctions.ts ├── jest │ ├── README.md │ ├── api.test.ts │ ├── api.ts │ ├── cloudwatch.test.ts │ ├── cloudwatch.ts │ ├── dynamoDb.test.ts │ ├── dynamoDb.ts │ ├── index.ts │ ├── kinesis.test.ts │ ├── kinesis.ts │ ├── s3.test.ts │ ├── s3.ts │ ├── sqs.test.ts │ ├── sqs.ts │ ├── stepFunctions.test.ts │ ├── stepFunctions.ts │ ├── utils.test.ts │ └── utils.ts └── utils │ ├── README.md │ ├── api.test.ts │ ├── api.ts │ ├── cloudwatch.test.ts │ ├── cloudwatch.ts │ ├── dynamoDb.test.ts │ ├── dynamoDb.ts │ ├── kinesis.test.ts │ ├── kinesis.ts │ ├── lambda.test.ts │ ├── lambda.ts │ ├── s3.test.ts │ ├── s3.ts │ ├── serverless.test.ts │ ├── serverless.ts │ ├── sqs.test.ts │ ├── sqs.ts │ ├── stepFunctions.test.ts │ └── stepFunctions.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | root: true, 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'prettier', 10 | ], 11 | rules: { 12 | '@typescript-eslint/explicit-module-boundary-types': 0, 13 | '@typescript-eslint/no-explicit-any': 0, 14 | '@typescript-eslint/no-namespace': 0, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge.automerge_dependencies] 4 | versions = ["minor", "patch"] 5 | usernames = ["renovate"] 6 | 7 | [approve] 8 | auto_approve_usernames = ["renovate", "erezrokah"] 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Before submitting a pull request,** please make sure the following is done: 2 | 3 | 1. Fork [the repository](https://github.com/erezrokah/aws-testing-library) and create your branch from `main` 4 | 2. Run `npm ci` in the repository root 5 | 3. If you fixed a bug or added code that should be tested, add tests! 6 | 4. Ensure the test suite passes (`npm test`) 7 | 5. Format your code with [prettier](https://github.com/prettier/prettier) (`npm run format`) 8 | 6. Make sure your code lints (`npm run lint`) 9 | 7. Run the [TypeScript](https://www.typescriptlang.org/) type checks (`npm run build`) 10 | 8. If applicable add an example in the [examples repository](https://github.com/erezrokah/aws-testing-library-examples) 11 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | name: release-please 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: GoogleCloudPlatform/release-please-action@v3 11 | id: release 12 | with: 13 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 14 | release-type: node 15 | package-name: aws-testing-library 16 | - uses: actions/checkout@v3 17 | if: ${{ steps.release.outputs.release_created }} 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: 'lts/*' 21 | cache: 'npm' 22 | registry-url: 'https://registry.npmjs.org' 23 | if: ${{ steps.release.outputs.release_created }} 24 | - name: Install core dependencies 25 | run: npm ci --no-audit 26 | if: ${{ steps.release.outputs.release_created }} 27 | - run: npm publish 28 | if: ${{ steps.release.outputs.release_created }} 29 | env: 30 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: AWS Testing Library CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [macos-latest, ubuntu-latest, windows-latest] 18 | node-version: ['16.10.0', 'lts/*'] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | 28 | - name: Install Dependencies 29 | run: npm ci 30 | 31 | - name: Run Linter 32 | run: npm run lint 33 | 34 | - name: Run Prettier 35 | run: npm run format:ci 36 | 37 | - name: Run Build 38 | run: npm run build 39 | 40 | - name: Run Tests 41 | run: npm run test 42 | 43 | - name: Report Tests Coverage 44 | run: npm run coverage 45 | 46 | - name: Coveralls 47 | uses: coverallsapp/github-action@master 48 | with: 49 | github-token: ${{ secrets.GITHUB_TOKEN }} 50 | flag-name: run-${{ matrix.os }}-node-${{ matrix.node-version }} 51 | parallel: true 52 | 53 | finish: 54 | needs: test 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Coveralls Finished 58 | uses: coverallsapp/github-action@master 59 | with: 60 | github-token: ${{ secrets.github_token }} 61 | parallel-finished: true 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | 4 | coverage 5 | 6 | lib 7 | 8 | out.txt 9 | 10 | reports 11 | 12 | junit.xml 13 | 14 | yarn-error.log 15 | # Local Netlify folder 16 | .netlify 17 | 18 | .vscode 19 | 20 | .env -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erezrokah/aws-testing-library/aede74f7c66527ff0273c29adde8ccb5b80453bc/.npmignore -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "useTabs": false, 8 | "overrides": [ 9 | { 10 | "files": "*.json", 11 | "options": { "printWidth": 200 } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at erezrokah@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | 3 | Everyone is welcome to contribute regardless of personal background. We enforce a [Code of conduct](CODE_OF_CONDUCT.md) in order to 4 | promote a positive and inclusive environment. 5 | 6 | ## Development process 7 | 8 | First fork and clone the repository. If you're not sure how to do this, please watch 9 | [these videos](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 10 | 11 | Run: 12 | 13 | ```bash 14 | npm ci 15 | ``` 16 | 17 | Tests are run with: 18 | 19 | ```bash 20 | npm test 21 | ``` 22 | 23 | In watch mode: 24 | 25 | ```bash 26 | npm run test:watch 27 | ``` 28 | 29 | We use `prettier` for formatting and `eslint` for linting. You can run those using 30 | 31 | ```bash 32 | npm run format 33 | npm run lint 34 | ``` 35 | 36 | ## Architecture 37 | 38 | This library extends `chai` and `jest`. Wrappers for `chai` and `jest` can be found under `src/chai` and `src/jest` accordingly. 39 | The wrappers use utilities from `src/utils` that abstract some of the complexity of dealing with AWS services APIs. 40 | 41 | ## Pull Requests 42 | 43 | We actively welcome your pull requests. 44 | 45 | **Before submitting a pull request,** please make sure the following is done: 46 | 47 | 1. Fork [the repository](https://github.com/erezrokah/aws-testing-library) and create your branch from `main` 48 | 2. Run `npm ci` in the repository root 49 | 3. If you fixed a bug or added code that should be tested, add tests! 50 | 4. Ensure the test suite passes (`npm test`) 51 | 5. Format your code with [prettier](https://github.com/prettier/prettier) (`npm run format`) 52 | 6. Make sure your code lints (`npm run lint`) 53 | 7. Run the [TypeScript](https://www.typescriptlang.org/) type checks (`npm run build`) 54 | 8. If applicable add an example in the [examples repository](https://github.com/erezrokah/aws-testing-library-examples) 55 | 56 | ## Releasing 57 | 58 | Merge the release PR 59 | 60 | ## License 61 | 62 | By contributing to AWS Testing Library, you agree that your contributions will be licensed 63 | under its [MIT license](LICENSE). 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2020 Erez Rokah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Testing Library 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | [![Build](https://github.com/erezrokah/aws-testing-library/workflows/AWS%20Testing%20Library%20CI/badge.svg)](https://github.com/erezrokah/aws-testing-library/actions) 5 | [![Coverage Status](https://coveralls.io/repos/github/erezrokah/aws-testing-library/badge.svg?branch=main)](https://coveralls.io/github/erezrokah/aws-testing-library?branch=main) 6 | 7 | > Note: If you're missing any capability please open an issue/feature request :) 8 | 9 | ## Prerequisites 10 | 11 | You should have your aws credentials under `~/.aws/credentials` (if you have [aws cli](https://aws.amazon.com/cli/) installed and configured). 12 | 13 | > Note: aws credentials are loaded automatically as described [here](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html) 14 | 15 | If you plan to use the [deploy](src/utils/README.md#deploy) utility function please install and configure [serverless](https://serverless.com/framework/docs/getting-started/). 16 | 17 | [node](https://nodejs.org/en/) >= 16.10.0. 18 | 19 | ## Installation 20 | 21 | Install with [npm](https://www.npmjs.com/) 22 | 23 | ```bash 24 | npm install aws-testing-library --save-dev 25 | ``` 26 | 27 | or [yarn](https://github.com/yarnpkg/yarn) 28 | 29 | ```bash 30 | yarn add aws-testing-library --dev 31 | ``` 32 | 33 | ## Usage 34 | 35 | - [Chai](src/chai/README.md) 36 | - [Jest](src/jest/README.md) 37 | - [Utils](src/utils/README.md) 38 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | const esModules = ['filter-obj'].join('|'); 3 | 4 | module.exports = { 5 | roots: ['/src'], 6 | preset: 'ts-jest/presets/js-with-ts', 7 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 8 | transformIgnorePatterns: [`node_modules/(?!${esModules})`], 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-testing-library", 3 | "version": "4.0.6", 4 | "description": "Chai and Jest matchers for aws services", 5 | "scripts": { 6 | "prepublishOnly": "npm run build", 7 | "lint": "eslint src/**/*.ts", 8 | "build": "tsc -p tsconfig.json", 9 | "test": "jest", 10 | "test:watch": "jest --watch", 11 | "coverage": "jest --coverage", 12 | "format": "prettier --write src/**/*.ts", 13 | "format:ci": "prettier --list-different src/**/*.ts", 14 | "prepare": "husky install" 15 | }, 16 | "files": [ 17 | "lib" 18 | ], 19 | "keywords": [ 20 | "serverless", 21 | "testing", 22 | "aws", 23 | "jest", 24 | "chai", 25 | "mocha", 26 | "s3", 27 | "dynamoDb", 28 | "api", 29 | "cloudwatch", 30 | "kinesis", 31 | "lambda", 32 | "sqs", 33 | "sns", 34 | "step-function" 35 | ], 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/erezrokah/aws-testing-library.git" 39 | }, 40 | "homepage": "https://github.com/erezrokah/aws-testing-library/#readme", 41 | "author": "Erez Rokah", 42 | "license": "MIT", 43 | "devDependencies": { 44 | "@commitlint/cli": "^17.0.0", 45 | "@commitlint/config-conventional": "^17.0.0", 46 | "@tsconfig/node16": "^1.0.3", 47 | "@types/chai": "^4.2.12", 48 | "@types/jest": "^29.0.0", 49 | "@types/mockdate": "^2.0.0", 50 | "@types/node": "^18.0.0", 51 | "@types/uuid": "^9.0.0", 52 | "@typescript-eslint/eslint-plugin": "^5.0.0", 53 | "@typescript-eslint/parser": "^5.0.0", 54 | "chai": "^4.2.0", 55 | "eslint": "^8.0.0", 56 | "eslint-config-prettier": "^8.0.0", 57 | "husky": "^8.0.0", 58 | "jest": "^29.0.0", 59 | "mockdate": "^3.0.0", 60 | "prettier": "^3.0.0", 61 | "ts-jest": "^29.0.0", 62 | "ts-node": "^10.0.0", 63 | "typescript": "^5.0.0" 64 | }, 65 | "dependencies": { 66 | "aws-sdk": "^2.678.0", 67 | "axios": "^0.29.0", 68 | "filter-obj": "^3.0.0", 69 | "jest-diff": "^29.0.0", 70 | "uuid": "^9.0.0" 71 | }, 72 | "engines": { 73 | "node": ">=16.10.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>erezrokah/shared-configurations:renovate-default"] 3 | } 4 | -------------------------------------------------------------------------------- /src/chai/README.md: -------------------------------------------------------------------------------- 1 | # Chai Setup 2 | 3 | ```js 4 | const awsTesting = require('aws-testing-library/lib/chai').default; 5 | const chai = require('chai'); 6 | chai.use(awsTesting); 7 | 8 | const { expect } = chai; 9 | 10 | // write assertions using expect 11 | ``` 12 | 13 | ## Usage with TypeScript 14 | 15 | ```typescript 16 | import awsTesting from 'aws-testing-library/lib/chai'; 17 | import chai = require('chai'); 18 | 19 | chai.use(awsTesting); 20 | 21 | const { expect } = chai; 22 | 23 | // write assertions using expect 24 | ``` 25 | 26 | ## Assertions 27 | 28 | > Notes 29 | > 30 | > - The matchers use `aws-sdk` under the hood, thus they are all asynchronous and require using `async/await` 31 | 32 | - [to.have.item()](#tohaveitem) 33 | - [to.have.object()](#tohaveobject) 34 | - [to.have.log()](#tohavelog) 35 | - [to.be.atState()](#tobeatstate) 36 | - [to.have.state()](#tohavestate) 37 | - [to.have.response()](#tohaveresponse) 38 | - [to.have.record()](#tohaverecord) 39 | - [to.have.message()](#tohavemessage) 40 | 41 | ### `to.have.item()` 42 | 43 | Asserts existence/equality of a DynamoDb item 44 | 45 | ```js 46 | await expect({ 47 | region: 'us-east-1', 48 | table: 'dynamo-db-table', 49 | timeout: 0 /* optional (defaults to 2500) */, 50 | pollEvery: 0 /* optional (defaults to 500) */, 51 | }).to.have.item( 52 | { 53 | id: 'itemId', 54 | } /* dynamoDb key object (https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#getItem-property) */, 55 | { 56 | id: 'itemId', 57 | createdAt: new Date().getTime(), 58 | text: 'some content', 59 | } /* optional, if exists will check equality in addition to existence */, 60 | true /* optional, strict mode comparison, defaults to true */, 61 | ); 62 | ``` 63 | 64 | [See complete example](https://github.com/erezrokah/serverless-monorepo-app/blob/master/services/db-service/e2e/db.chai.test.ts) 65 | 66 | ### `to.have.object()` 67 | 68 | Asserts existence/equality of a S3 object 69 | 70 | ```js 71 | await expect({ 72 | region: 'us-east-1', 73 | bucket: 's3-bucket', 74 | timeout: 0 /* optional (defaults to 2500) */, 75 | pollEvery: 0 /* optional (defaults to 500) */, 76 | }).to.have.object( 77 | 'someFileInTheBucket' /* a string representing the object key in the bucket */, 78 | Buffer.from( 79 | 'a buffer of the file content', 80 | ) /* optional, if exists will check equality in addition to existence */, 81 | ); 82 | ``` 83 | 84 | [See complete example](https://github.com/erezrokah/serverless-monorepo-app/blob/master/services/file-service/e2e/handler.chai.test.ts) 85 | 86 | ### `to.have.log()` 87 | 88 | Asserts existence of a cloudwatch log message 89 | 90 | ```js 91 | await expect({ 92 | region: 'us-east-1', 93 | // use either an explicit log group 94 | logGroupName: 'logGroupName', 95 | // or a function name to match a lambda function logs 96 | function: 'functionName', 97 | startTime: 0 /* optional (milliseconds since epoch in UTC, defaults to now-1 hour) */, 98 | timeout: 0 /* optional (defaults to 2500) */, 99 | pollEvery: 0 /* optional (defaults to 500) */, 100 | }).to.have.log( 101 | 'some message written to log' /* a pattern to match against log messages */, 102 | ); 103 | ``` 104 | 105 | ### `to.be.atState()` 106 | 107 | Asserts a state machine current state 108 | 109 | ```js 110 | await expect({ 111 | pollEvery: 5000 /* optional (defaults to 500) */, 112 | region: 'us-east-1', 113 | stateMachineArn: 'stateMachineArn', 114 | timeout: 30 * 1000 /* optional (defaults to 2500) */, 115 | }).to.be.atState('ExpectedState'); 116 | ``` 117 | 118 | ### `to.have.state()` 119 | 120 | Asserts that a state machine has been at a state 121 | 122 | ```js 123 | await expect({ 124 | pollEvery: 5000 /* optional (defaults to 500) */, 125 | region: 'us-east-1', 126 | stateMachineArn: 'stateMachineArn', 127 | timeout: 30 * 1000 /* optional (defaults to 2500) */, 128 | }).to.have.state('ExpectedState'); 129 | ``` 130 | 131 | ### `to.have.response()` 132 | 133 | Asserts that an api returns a specific response 134 | 135 | ```js 136 | await expect({ 137 | url: 'https://api-id.execute-api.us-east-1.amazonaws.com/dev/api/private', 138 | method: 'POST', 139 | params: { urlParam: 'value' } /* optional URL parameters */, 140 | data: { bodyParam: 'value' } /* optional body parameters */, 141 | headers: { Authorization: 'Bearer token_value' } /* optional headers */, 142 | }).to.have.response({ 143 | data: { 144 | message: 'Hello World!', 145 | }, 146 | statusCode: 200, 147 | }); 148 | ``` 149 | 150 | [See complete example](https://github.com/erezrokah/serverless-monorepo-app/blob/master/services/api-service/e2e/publicEndpoint.chai.test.ts) 151 | 152 | ### `to.have.record()` 153 | 154 | Asserts existence/equality of a Kinesis record 155 | 156 | ```js 157 | await expect({ 158 | region: 'us-east-1', 159 | stream: 'kinesis-stream', 160 | timeout: 0 /* optional (defaults to 10000) */, 161 | pollEvery: 0 /* optional (defaults to 500) */, 162 | }).to.have.record( 163 | (item) => item.id === 'someId' /* predicate to match with the stream data */, 164 | ); 165 | ``` 166 | 167 | [See complete example](https://github.com/erezrokah/serverless-monorepo-app/blob/master/services/kinesis-service/e2e/handler.chai.test.ts) 168 | 169 | ### `to.have.message()` 170 | 171 | Asserts existence/equality of a message in an SQS queue 172 | 173 | ```js 174 | const { 175 | subscribeToTopic, 176 | unsubscribeFromTopic, 177 | } = require('aws-testing-library/lib/utils/sqs'); 178 | 179 | let [subscriptionArn, queueUrl] = ['', '']; 180 | try { 181 | // create an SQS queue and subscribe to SNS topic 182 | ({ subscriptionArn, queueUrl } = await subscribeToTopic(region, topicArn)); 183 | 184 | // run some code that will publish a message to the SNS topic 185 | someCodeThatResultsInPublishingAMessage(); 186 | 187 | await expect({ 188 | region, 189 | queueUrl, 190 | timeout: 10000 /* optional (defaults to 2500) */, 191 | pollEvery: 2500 /* optional (defaults to 500) */, 192 | }).to.have.message( 193 | /* predicate to match with the messages in the queue */ 194 | (message) => 195 | message.Subject === 'Some Subject' && message.Message === 'Some Message', 196 | ); 197 | } finally { 198 | // unsubscribe from SNS topic and delete SQS queue 199 | await unsubscribeFromTopic(region, subscriptionArn, queueUrl); 200 | } 201 | ``` 202 | 203 | [See complete example](https://github.com/erezrokah/serverless-monitoring-app/blob/master/services/monitoring-tester-service/e2e/checkEndpointStepFunction.chai.test.ts) 204 | -------------------------------------------------------------------------------- /src/chai/api.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { Method } from 'axios'; 3 | import chai = require('chai'); 4 | import './'; 5 | import api from './api'; 6 | 7 | jest.mock('../common'); 8 | jest.mock('../utils/api'); 9 | jest.mock('./utils', () => { 10 | return { wrapWithRetries: jest.fn((f) => f) }; 11 | }); 12 | 13 | chai.use(api); 14 | 15 | describe('api', () => { 16 | describe('response', () => { 17 | const url = 'url'; 18 | const method = 'POST' as Method; 19 | const params = { param1: 'param1' }; 20 | const data = { data1: 'data1' }; 21 | const headers = { header1: 'header1' }; 22 | 23 | const props = { url, method, params, data, headers }; 24 | 25 | beforeEach(() => { 26 | jest.clearAllMocks(); 27 | }); 28 | 29 | test('should throw error on getResponse error', async () => { 30 | const { verifyProps } = require('../common'); 31 | const { getResponse } = require('../utils/api'); 32 | const { wrapWithRetries } = require('./utils'); 33 | 34 | const error = new Error('Unknown error'); 35 | getResponse.mockReturnValue(Promise.reject(error)); 36 | 37 | const expected = { statusCode: 200, data: { id: 'id' } }; 38 | 39 | expect.assertions(6); 40 | 41 | let received = null; 42 | try { 43 | await chai.expect(props).to.have.response(expected); 44 | } catch (e) { 45 | received = e; 46 | } 47 | 48 | expect(error).toBe(received); 49 | 50 | expect(verifyProps).toHaveBeenCalledTimes(1); 51 | expect(verifyProps).toHaveBeenCalledWith({ ...props }, ['url', 'method']); 52 | 53 | expect(getResponse).toHaveBeenCalledTimes(1); 54 | expect(getResponse).toHaveBeenCalledWith( 55 | url, 56 | method, 57 | params, 58 | data, 59 | headers, 60 | ); 61 | expect(wrapWithRetries).toHaveBeenCalledTimes(1); 62 | }); 63 | 64 | test('should pass on have response', async () => { 65 | const { getResponse } = require('../utils/api'); 66 | 67 | const actual = { statusCode: 200, data: { id: 'id' } }; 68 | getResponse.mockReturnValue(Promise.resolve(actual)); 69 | 70 | const expected = { statusCode: 200, data: { id: 'id' } }; 71 | 72 | expect.assertions(2); 73 | 74 | // should not throw error on same response 75 | await chai.expect(props).to.have.response(expected); 76 | 77 | try { 78 | // should throw error on different response 79 | getResponse.mockReturnValue(Promise.resolve({ statusCode: 404 })); 80 | await chai.expect(props).to.have.response(expected); 81 | } catch (error) { 82 | const e = error as Error; 83 | expect(e).toBeInstanceOf(chai.AssertionError); 84 | expect(e.message).toBe( 85 | "expected { statusCode: 200, data: { id: 'id' } } to be equal to { statusCode: 404 }", 86 | ); 87 | } 88 | }); 89 | 90 | test('should pass on not have response', async () => { 91 | const { getResponse } = require('../utils/api'); 92 | 93 | const actual = { statusCode: 404, data: {} }; 94 | getResponse.mockReturnValue(Promise.resolve(actual)); 95 | 96 | const expected = { statusCode: 200, data: { id: 'id' } }; 97 | 98 | expect.assertions(2); 99 | 100 | // should not throw error on different response 101 | await chai.expect(props).not.to.have.response(expected); 102 | try { 103 | // should throw error on same response 104 | await chai.expect(props).not.to.have.response(actual); 105 | } catch (error) { 106 | const e = error as Error; 107 | expect(e).toBeInstanceOf(chai.AssertionError); 108 | expect(e.message).toBe( 109 | 'expected { statusCode: 404, data: {} } to not be equal to { statusCode: 404, data: {} }', 110 | ); 111 | } 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/chai/api.ts: -------------------------------------------------------------------------------- 1 | import { verifyProps } from '../common'; 2 | import { expectedProps, IApiProps, IExpectedResponse } from '../common/api'; 3 | import { getResponse } from '../utils/api'; 4 | import { wrapWithRetries } from './utils'; 5 | 6 | const attemptApi = async function ( 7 | this: any, 8 | eql: any, 9 | objDisplay: any, 10 | expected: IExpectedResponse, 11 | ) { 12 | const props = this._obj as IApiProps; 13 | 14 | verifyProps(props, expectedProps); 15 | 16 | const { url, method, params, data, headers } = props; 17 | const received = await getResponse(url, method, params, data, headers); 18 | 19 | const deepEquals = eql(expected, received); 20 | return { 21 | message: `expected ${objDisplay(expected)} to be equal to ${objDisplay( 22 | received, 23 | )}`, 24 | negateMessage: `expected ${objDisplay( 25 | expected, 26 | )} to not be equal to ${objDisplay(received)}`, 27 | pass: deepEquals, 28 | }; 29 | }; 30 | 31 | const api = (chai: any, { eql, objDisplay }: any) => { 32 | chai.Assertion.addMethod( 33 | 'response', 34 | async function (this: any, expected: IExpectedResponse) { 35 | const wrapped = wrapWithRetries(attemptApi); 36 | const { pass, message, negateMessage } = await wrapped.apply(this, [ 37 | eql, 38 | objDisplay, 39 | expected, 40 | ]); 41 | 42 | this.assert(pass, message, negateMessage); 43 | }, 44 | ); 45 | }; 46 | 47 | export default api; 48 | -------------------------------------------------------------------------------- /src/chai/cloudwatch.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import chai = require('chai'); 3 | import * as common from '../common'; 4 | import './'; 5 | import cloudwatch from './cloudwatch'; 6 | 7 | jest.mock('../utils/cloudwatch'); 8 | jest.mock('./utils', () => { 9 | return { wrapWithRetries: jest.fn((f) => f) }; 10 | }); 11 | 12 | jest.spyOn(Date, 'parse').mockImplementation(() => 12 * 60 * 60 * 1000); 13 | jest.spyOn(common, 'verifyProps'); 14 | jest.spyOn(common, 'epochDateMinusHours'); 15 | 16 | chai.use(cloudwatch); 17 | 18 | describe('cloudwatch', () => { 19 | describe('log', () => { 20 | const region = 'region'; 21 | const functionName = 'functionName'; 22 | const startTime = 12 * 60 * 60 * 1000; 23 | 24 | const props = { region, function: functionName, startTime }; 25 | const pattern = 'pattern'; 26 | 27 | beforeEach(() => { 28 | jest.clearAllMocks(); 29 | 30 | const { getLogGroupName } = require('../utils/cloudwatch'); 31 | getLogGroupName.mockImplementation( 32 | (functionName: string) => `/aws/lambda/${functionName}`, 33 | ); 34 | }); 35 | 36 | test('should throw error on filterLogEvents error', async () => { 37 | const { verifyProps } = require('../common'); 38 | const { filterLogEvents } = require('../utils/cloudwatch'); 39 | const { wrapWithRetries } = require('./utils'); 40 | 41 | const error = new Error('Unknown error'); 42 | filterLogEvents.mockReturnValue(Promise.reject(error)); 43 | 44 | expect.assertions(6); 45 | 46 | let received = null; 47 | try { 48 | await chai.expect(props).to.have.log(pattern); 49 | } catch (e) { 50 | received = e; 51 | } 52 | 53 | expect(error).toBe(received); 54 | 55 | expect(verifyProps).toHaveBeenCalledTimes(1); 56 | expect(verifyProps).toHaveBeenCalledWith({ ...props, pattern }, [ 57 | 'region', 58 | 'pattern', 59 | ]); 60 | 61 | expect(filterLogEvents).toHaveBeenCalledTimes(1); 62 | expect(filterLogEvents).toHaveBeenCalledWith( 63 | region, 64 | `/aws/lambda/${functionName}`, 65 | startTime, 66 | pattern, 67 | ); 68 | expect(wrapWithRetries).toHaveBeenCalledTimes(1); 69 | }); 70 | 71 | test('startTime should be defaulted when not passed in', async () => { 72 | const { epochDateMinusHours, verifyProps } = require('../common'); 73 | const { filterLogEvents } = require('../utils/cloudwatch'); 74 | 75 | filterLogEvents.mockReturnValue(Promise.resolve({ events: ['event'] })); 76 | epochDateMinusHours.mockReturnValue(11 * 60 * 60 * 1000); 77 | 78 | const propsNoTime = { region, function: functionName }; 79 | await chai.expect(propsNoTime).to.have.log(pattern); 80 | 81 | expect(filterLogEvents).toHaveBeenCalledTimes(1); 82 | expect(filterLogEvents).toHaveBeenCalledWith( 83 | propsNoTime.region, 84 | `/aws/lambda/${functionName}`, 85 | 11 * 60 * 60 * 1000, 86 | pattern, 87 | ); 88 | expect(verifyProps).toHaveBeenCalledTimes(1); 89 | expect(verifyProps).toHaveBeenCalledWith({ ...propsNoTime, pattern }, [ 90 | 'region', 91 | 'pattern', 92 | ]); 93 | }); 94 | 95 | test('should pass custom log group name to filterLogEvents', async () => { 96 | const { filterLogEvents } = require('../utils/cloudwatch'); 97 | 98 | filterLogEvents.mockReturnValue(Promise.resolve({ events: ['event'] })); 99 | 100 | await chai 101 | .expect({ ...props, logGroupName: 'customLogGroup' }) 102 | .to.have.log(pattern); 103 | 104 | expect(filterLogEvents).toHaveBeenCalledTimes(1); 105 | expect(filterLogEvents).toHaveBeenCalledWith( 106 | props.region, 107 | 'customLogGroup', 108 | props.startTime, 109 | pattern, 110 | ); 111 | }); 112 | 113 | test('should pass on have log', async () => { 114 | const { filterLogEvents } = require('../utils/cloudwatch'); 115 | 116 | filterLogEvents.mockReturnValue(Promise.resolve({ events: ['event'] })); 117 | 118 | expect.assertions(2); 119 | 120 | // should not throw error on some events 121 | await chai.expect(props).to.have.log(pattern); 122 | 123 | try { 124 | // should throw error on no events 125 | filterLogEvents.mockReturnValue(Promise.resolve({ events: [] })); 126 | await chai.expect(props).to.have.log(pattern); 127 | } catch (error) { 128 | const e = error as Error; 129 | expect(e).toBeInstanceOf(chai.AssertionError); 130 | expect(e.message).toBe( 131 | `expected ${functionName} to have log matching ${pattern}`, 132 | ); 133 | } 134 | }); 135 | 136 | test('should pass on not have log', async () => { 137 | const { filterLogEvents } = require('../utils/cloudwatch'); 138 | 139 | filterLogEvents.mockReturnValue(Promise.resolve({ events: [] })); 140 | 141 | expect.assertions(2); 142 | 143 | // should not throw error on no events 144 | await chai.expect(props).to.not.have.log(pattern); 145 | 146 | try { 147 | // should throw error on some events 148 | filterLogEvents.mockReturnValue(Promise.resolve({ events: ['event'] })); 149 | await chai.expect(props).to.not.have.log(pattern); 150 | } catch (error) { 151 | const e = error as Error; 152 | expect(e).toBeInstanceOf(chai.AssertionError); 153 | expect(e.message).toBe( 154 | `expected ${functionName} not to have log matching ${pattern}`, 155 | ); 156 | } 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/chai/cloudwatch.ts: -------------------------------------------------------------------------------- 1 | import { epochDateMinusHours, verifyProps } from '../common'; 2 | import { expectedProps, ICloudwatchProps } from '../common/cloudwatch'; 3 | import { filterLogEvents, getLogGroupName } from '../utils/cloudwatch'; 4 | import { wrapWithRetries } from './utils'; 5 | 6 | const attemptCloudwatch = async function (this: any, pattern: string) { 7 | const props = this._obj as ICloudwatchProps; 8 | 9 | verifyProps({ ...props, pattern }, expectedProps); 10 | 11 | const { 12 | region, 13 | function: functionName, 14 | startTime = epochDateMinusHours(1), 15 | logGroupName, 16 | } = props; 17 | 18 | const { events } = await filterLogEvents( 19 | region, 20 | logGroupName || getLogGroupName(functionName || ''), 21 | startTime, 22 | pattern, 23 | ); 24 | const found = events.length > 0; 25 | 26 | const messageSubject = logGroupName || functionName; 27 | return { 28 | message: `expected ${messageSubject} to have log matching ${pattern}`, 29 | negateMessage: `expected ${messageSubject} not to have log matching ${pattern}`, 30 | pass: found, 31 | }; 32 | }; 33 | 34 | const cloudwatch = (chai: any) => { 35 | chai.Assertion.addMethod('log', async function (this: any, pattern: string) { 36 | const wrapped = wrapWithRetries(attemptCloudwatch); 37 | const { pass, message, negateMessage } = await wrapped.apply(this, [ 38 | pattern, 39 | ]); 40 | 41 | this.assert(pass, message, negateMessage); 42 | }); 43 | }; 44 | 45 | export default cloudwatch; 46 | -------------------------------------------------------------------------------- /src/chai/dynamoDb.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import chai = require('chai'); 3 | import './'; 4 | import dynamoDb from './dynamoDb'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/dynamoDb'); 8 | jest.mock('./utils', () => { 9 | return { wrapWithRetries: jest.fn((f) => f) }; 10 | }); 11 | 12 | chai.use(dynamoDb); 13 | 14 | describe('dynamoDb', () => { 15 | describe('item', () => { 16 | const region = 'region'; 17 | const table = 'table'; 18 | const props = { region, table }; 19 | const key = { id: { S: 'id' } }; 20 | 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | test('should throw error on filterLogEvents error', async () => { 26 | const { verifyProps } = require('../common'); 27 | const { getItem } = require('../utils/dynamoDb'); 28 | const { wrapWithRetries } = require('./utils'); 29 | 30 | const error = new Error('Unknown error'); 31 | getItem.mockReturnValue(Promise.reject(error)); 32 | 33 | expect.assertions(6); 34 | 35 | let received = null; 36 | try { 37 | await chai.expect(props).to.have.item(key); 38 | } catch (e) { 39 | received = e; 40 | } 41 | 42 | expect(error).toBe(received); 43 | 44 | expect(verifyProps).toHaveBeenCalledTimes(1); 45 | expect(verifyProps).toHaveBeenCalledWith({ ...props, key }, [ 46 | 'region', 47 | 'table', 48 | 'key', 49 | ]); 50 | 51 | expect(getItem).toHaveBeenCalledTimes(1); 52 | expect(getItem).toHaveBeenCalledWith(region, table, key); 53 | 54 | expect(wrapWithRetries).toHaveBeenCalledTimes(1); 55 | }); 56 | 57 | test('should pass on have item', async () => { 58 | const { getItem } = require('../utils/dynamoDb'); 59 | 60 | getItem.mockReturnValue(Promise.resolve({ id: 'id1' })); 61 | 62 | expect.assertions(2); 63 | 64 | // should not throw error on item exists 65 | await chai.expect(props).to.have.item(key); 66 | 67 | try { 68 | // should throw error on no item 69 | getItem.mockReturnValue(Promise.resolve(undefined)); 70 | await chai.expect(props).to.have.item(key); 71 | } catch (error) { 72 | const e = error as Error; 73 | expect(e).toBeInstanceOf(chai.AssertionError); 74 | expect(e.message).toBe( 75 | `expected ${table} to have item with key ${JSON.stringify(key)}`, 76 | ); 77 | } 78 | }); 79 | 80 | test('should pass on not have item', async () => { 81 | const { getItem } = require('../utils/dynamoDb'); 82 | 83 | getItem.mockReturnValue(Promise.resolve(undefined)); 84 | 85 | expect.assertions(2); 86 | 87 | // should not throw error on no item 88 | await chai.expect(props).to.not.have.item(key); 89 | 90 | try { 91 | // should throw error on item exists 92 | getItem.mockReturnValue(Promise.resolve({ id: 'id1' })); 93 | await chai.expect(props).to.not.have.item(key); 94 | } catch (error) { 95 | const e = error as Error; 96 | expect(e).toBeInstanceOf(chai.AssertionError); 97 | expect(e.message).toBe( 98 | `expected ${table} not to have item with key ${JSON.stringify(key)}`, 99 | ); 100 | } 101 | }); 102 | 103 | test('should pass on item equals', async () => { 104 | const { getItem } = require('../utils/dynamoDb'); 105 | 106 | getItem.mockReturnValue(Promise.resolve({ id: { S: 'someId' } })); 107 | 108 | expect.assertions(2); 109 | 110 | const expected = { id: { S: 'someId' } }; 111 | // should not throw error on item equals 112 | await chai.expect(props).to.have.item(key, expected); 113 | try { 114 | // should throw error on item not equals 115 | getItem.mockReturnValue(Promise.resolve({ id: 'otherId' })); 116 | await chai.expect(props).to.have.item(key, expected); 117 | } catch (error) { 118 | const e = error as Error; 119 | expect(e).toBeInstanceOf(chai.AssertionError); 120 | expect(e.message).toBe( 121 | "expected { id: { S: 'someId' } } to be equal to { id: 'otherId' }", 122 | ); 123 | } 124 | }); 125 | 126 | test('should pass on item not equals', async () => { 127 | const { getItem } = require('../utils/dynamoDb'); 128 | 129 | getItem.mockReturnValue(Promise.resolve({ id: { S: 'otherId' } })); 130 | 131 | expect.assertions(2); 132 | 133 | const expected = { id: { S: 'someId' } }; 134 | // should not throw error on item not equals 135 | await chai.expect(props).to.not.have.item(key, expected); 136 | try { 137 | // should throw error on item equals 138 | getItem.mockReturnValue(Promise.resolve({ id: { S: 'someId' } })); 139 | await chai.expect(props).to.not.have.item(key, expected); 140 | } catch (error) { 141 | const e = error as Error; 142 | expect(e).toBeInstanceOf(chai.AssertionError); 143 | expect(e.message).toBe( 144 | "expected { id: { S: 'someId' } } to not be equal to { id: { S: 'someId' } }", 145 | ); 146 | } 147 | }); 148 | 149 | test('should pass on item equals non strict mode', async () => { 150 | const { getItem } = require('../utils/dynamoDb'); 151 | 152 | const actual = { 153 | id: { S: 'someId' }, 154 | timestamp: { N: '10000000000' }, 155 | }; 156 | getItem.mockReturnValue(Promise.resolve(actual)); 157 | 158 | expect.assertions(2); 159 | 160 | const expected = { id: { S: 'someId' } }; 161 | // should not throw error on item equals non strict 162 | await chai.expect(props).to.have.item(key, expected, false); 163 | try { 164 | // should throw error on item not equals 165 | await chai.expect(props).to.have.item(key, expected, true); 166 | } catch (error) { 167 | const e = error as Error; 168 | expect(e).toBeInstanceOf(chai.AssertionError); 169 | expect(e.message).toBe( 170 | "expected { id: { S: 'someId' } } to be equal to { id: { S: 'someId' }, …(1) }", 171 | ); 172 | } 173 | }); 174 | 175 | test('should pass on item not equals non strict mode', async () => { 176 | const { getItem } = require('../utils/dynamoDb'); 177 | 178 | const actual = { 179 | id: { S: 'someId' }, 180 | timestamp: { N: '10000000000' }, 181 | }; 182 | getItem.mockReturnValue(Promise.resolve(actual)); 183 | 184 | expect.assertions(2); 185 | 186 | const expected = { id: { S: 'someId' } }; 187 | // should not throw error on item not equals 188 | await chai.expect(props).to.not.have.item(key, expected, true); 189 | try { 190 | // should throw error on item equals non strict 191 | await chai.expect(props).to.not.have.item(key, expected, false); 192 | } catch (error) { 193 | const e = error as Error; 194 | expect(e).toBeInstanceOf(chai.AssertionError); 195 | expect(e.message).toBe( 196 | "expected { id: { S: 'someId' } } to not be equal to { id: { S: 'someId' } }", 197 | ); 198 | } 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /src/chai/dynamoDb.ts: -------------------------------------------------------------------------------- 1 | import { verifyProps } from '../common'; 2 | import { 3 | expectedProps, 4 | IDynamoDbProps, 5 | removeKeysFromItemForNonStrictComparison, 6 | } from '../common/dynamoDb'; 7 | import { getItem } from '../utils/dynamoDb'; 8 | import { wrapWithRetries } from './utils'; 9 | 10 | const attemptDynamoDb = async function ( 11 | this: any, 12 | eql: any, 13 | objDisplay: any, 14 | key: AWS.DynamoDB.DocumentClient.Key, 15 | expected: AWS.DynamoDB.DocumentClient.AttributeMap, 16 | strict: boolean, 17 | ) { 18 | const props = this._obj as IDynamoDbProps; 19 | verifyProps({ ...props, key }, expectedProps); 20 | 21 | const { region, table } = props; 22 | let received = await getItem(region, table, key); 23 | 24 | const printKey = JSON.stringify(key); 25 | 26 | if (received && expected) { 27 | // check equality as well 28 | if (!strict) { 29 | received = removeKeysFromItemForNonStrictComparison(received, expected); 30 | } 31 | const deepEquals = eql(expected, received); 32 | return { 33 | message: `expected ${objDisplay(expected)} to be equal to ${objDisplay( 34 | received, 35 | )}`, 36 | negateMessage: `expected ${objDisplay( 37 | expected, 38 | )} to not be equal to ${objDisplay(received)}`, 39 | pass: deepEquals, 40 | }; 41 | } else { 42 | // only check existence 43 | return { 44 | message: `expected ${table} to have item with key ${printKey}`, 45 | negateMessage: `expected ${table} not to have item with key ${printKey}`, 46 | pass: received, 47 | }; 48 | } 49 | }; 50 | 51 | const dynamoDb = (chai: any, { eql, objDisplay }: any) => { 52 | chai.Assertion.addMethod( 53 | 'item', 54 | async function ( 55 | this: any, 56 | key: AWS.DynamoDB.DocumentClient.Key, 57 | expected?: AWS.DynamoDB.DocumentClient.AttributeMap, 58 | strict = true, 59 | ) { 60 | const wrapped = wrapWithRetries(attemptDynamoDb); 61 | const { pass, message, negateMessage } = await wrapped.apply(this, [ 62 | eql, 63 | objDisplay, 64 | key, 65 | expected, 66 | strict, 67 | ]); 68 | 69 | this.assert(pass, message, negateMessage); 70 | }, 71 | ); 72 | }; 73 | 74 | export default dynamoDb; 75 | -------------------------------------------------------------------------------- /src/chai/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | jest.mock('./api'); 3 | jest.mock('./cloudwatch'); 4 | jest.mock('./dynamoDb'); 5 | jest.mock('./kinesis'); 6 | jest.mock('./s3'); 7 | jest.mock('./sqs'); 8 | jest.mock('./stepFunctions'); 9 | 10 | describe('index', () => { 11 | test('calls all modules functions', () => { 12 | const awsTesting = require('./').default; 13 | const [api, cloudwatch, dynamoDb, kinesis, s3, sqs, stepFunctions] = [ 14 | require('./api').default, 15 | require('./cloudwatch').default, 16 | require('./dynamoDb').default, 17 | require('./kinesis').default, 18 | require('./s3').default, 19 | require('./sqs').default, 20 | require('./stepFunctions').default, 21 | ]; 22 | 23 | const chai = jest.fn(); 24 | const utils = jest.fn(); 25 | 26 | awsTesting(chai, utils); 27 | 28 | expect(api).toHaveBeenCalledTimes(1); 29 | expect(api).toHaveBeenCalledWith(chai, utils); 30 | 31 | expect(cloudwatch).toHaveBeenCalledTimes(1); 32 | expect(cloudwatch).toHaveBeenCalledWith(chai); 33 | 34 | expect(dynamoDb).toHaveBeenCalledTimes(1); 35 | expect(dynamoDb).toHaveBeenCalledWith(chai, utils); 36 | 37 | expect(kinesis).toHaveBeenCalledTimes(1); 38 | expect(kinesis).toHaveBeenCalledWith(chai); 39 | 40 | expect(s3).toHaveBeenCalledTimes(1); 41 | expect(s3).toHaveBeenCalledWith(chai, utils); 42 | 43 | expect(sqs).toHaveBeenCalledTimes(1); 44 | expect(sqs).toHaveBeenCalledWith(chai); 45 | 46 | expect(stepFunctions).toHaveBeenCalledTimes(1); 47 | expect(stepFunctions).toHaveBeenCalledWith(chai); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/chai/index.ts: -------------------------------------------------------------------------------- 1 | import { IExpectedResponse } from '../common/api'; 2 | import { IRecordMatcher } from '../utils/kinesis'; 3 | import { IMessageMatcher } from '../utils/sqs'; 4 | 5 | import api from './api'; 6 | import cloudwatch from './cloudwatch'; 7 | import dynamoDb from './dynamoDb'; 8 | import kinesis from './kinesis'; 9 | import s3 from './s3'; 10 | import sqs from './sqs'; 11 | import stepFunctions from './stepFunctions'; 12 | 13 | declare global { 14 | namespace Chai { 15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 16 | interface Assertion { 17 | response: (expected: IExpectedResponse) => Assertion; 18 | log: (pattern: string) => Assertion; 19 | item: ( 20 | key: AWS.DynamoDB.DocumentClient.Key, 21 | expectedItem?: AWS.DynamoDB.DocumentClient.AttributeMap, 22 | strict?: boolean, 23 | ) => Assertion; 24 | record: (matcher: IRecordMatcher) => Assertion; 25 | object: (key: string, expected?: Buffer) => Assertion; 26 | message: (matcher: IMessageMatcher) => Assertion; 27 | atState: (state: string) => Assertion; 28 | state: (state: string) => Assertion; 29 | } 30 | } 31 | } 32 | 33 | const awsTesting = function (this: any, chai: any, utils: any) { 34 | api(chai, utils); 35 | cloudwatch(chai); 36 | dynamoDb(chai, utils); 37 | kinesis(chai); 38 | s3(chai, utils); 39 | sqs(chai); 40 | stepFunctions(chai); 41 | }; 42 | 43 | export default awsTesting; 44 | -------------------------------------------------------------------------------- /src/chai/kinesis.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import chai = require('chai'); 3 | import './'; 4 | import kinesis from './kinesis'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/kinesis'); 8 | 9 | chai.use(kinesis); 10 | 11 | describe('kinesis', () => { 12 | describe('record', () => { 13 | const region = 'region'; 14 | const stream = 'stream'; 15 | const props = { region, stream }; 16 | const matcher = jest.fn(); 17 | 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | test('should throw error on existsInStream error', async () => { 23 | const { verifyProps } = require('../common'); 24 | const { existsInStream } = require('../utils/kinesis'); 25 | 26 | const error = new Error('Unknown error'); 27 | existsInStream.mockReturnValue(Promise.reject(error)); 28 | 29 | expect.assertions(5); 30 | 31 | let received = null; 32 | try { 33 | await chai.expect(props).to.have.record(matcher); 34 | } catch (e) { 35 | received = e; 36 | } 37 | 38 | expect(error).toBe(received); 39 | 40 | expect(verifyProps).toHaveBeenCalledTimes(1); 41 | expect(verifyProps).toHaveBeenCalledWith({ ...props, matcher }, [ 42 | 'region', 43 | 'stream', 44 | 'matcher', 45 | ]); 46 | 47 | expect(existsInStream).toHaveBeenCalledTimes(1); 48 | expect(existsInStream).toHaveBeenCalledWith( 49 | region, 50 | stream, 51 | matcher, 52 | 10 * 1000, 53 | 500, 54 | ); 55 | }); 56 | 57 | test('should pass on have record', async () => { 58 | const { existsInStream } = require('../utils/kinesis'); 59 | 60 | existsInStream.mockReturnValue(Promise.resolve(true)); 61 | 62 | expect.assertions(2); 63 | 64 | // should not throw error on record exists 65 | await chai.expect(props).to.have.record(matcher); 66 | 67 | try { 68 | // should throw error on no record 69 | existsInStream.mockReturnValue(Promise.resolve(false)); 70 | await chai.expect(props).to.have.record(matcher); 71 | } catch (error) { 72 | const e = error as Error; 73 | expect(e).toBeInstanceOf(chai.AssertionError); 74 | expect(e.message).toBe(`expected ${stream} to have record`); 75 | } 76 | }); 77 | 78 | test('should pass on not have record', async () => { 79 | const { existsInStream } = require('../utils/kinesis'); 80 | 81 | existsInStream.mockReturnValue(Promise.resolve(false)); 82 | 83 | expect.assertions(2); 84 | 85 | // should not throw error on no record 86 | await chai.expect(props).to.not.have.record(matcher); 87 | 88 | try { 89 | // should throw error on record exists 90 | existsInStream.mockReturnValue(Promise.resolve(true)); 91 | await chai.expect(props).to.not.have.record(matcher); 92 | } catch (error) { 93 | const e = error as Error; 94 | expect(e).toBeInstanceOf(chai.AssertionError); 95 | expect(e.message).toBe(`expected ${stream} not to have record`); 96 | } 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/chai/kinesis.ts: -------------------------------------------------------------------------------- 1 | import { verifyProps } from '../common'; 2 | import { expectedProps, IKinesisProps } from '../common/kinesis'; 3 | import { existsInStream, IRecordMatcher } from '../utils/kinesis'; 4 | 5 | const kinesis = (chai: any) => { 6 | chai.Assertion.addMethod( 7 | 'record', 8 | async function (this: any, matcher: IRecordMatcher) { 9 | const props = this._obj as IKinesisProps; 10 | verifyProps({ ...props, matcher }, expectedProps); 11 | 12 | const { region, stream, timeout = 10 * 1000, pollEvery = 500 } = props; 13 | const found = await existsInStream( 14 | region, 15 | stream, 16 | matcher, 17 | timeout, 18 | pollEvery, 19 | ); 20 | 21 | this.assert( 22 | found, 23 | `expected ${stream} to have record`, 24 | `expected ${stream} not to have record`, 25 | ); 26 | }, 27 | ); 28 | }; 29 | 30 | export default kinesis; 31 | -------------------------------------------------------------------------------- /src/chai/s3.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import chai = require('chai'); 3 | import './'; 4 | import s3 from './s3'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/s3'); 8 | jest.mock('./utils', () => { 9 | return { wrapWithRetries: jest.fn((f) => f) }; 10 | }); 11 | 12 | chai.use(s3); 13 | 14 | const utils = (chai as any).util; 15 | 16 | describe('s3', () => { 17 | describe('object', () => { 18 | const region = 'region'; 19 | const bucket = 'bucket'; 20 | const props = { region, bucket }; 21 | const key = 'key'; 22 | 23 | beforeEach(() => { 24 | jest.clearAllMocks(); 25 | }); 26 | 27 | test('should throw error on existsInStream error', async () => { 28 | const { verifyProps } = require('../common'); 29 | const { getObject } = require('../utils/s3'); 30 | const { wrapWithRetries } = require('./utils'); 31 | 32 | const error = new Error('Unknown error'); 33 | getObject.mockReturnValue(Promise.reject(error)); 34 | 35 | expect.assertions(6); 36 | 37 | let received = null; 38 | try { 39 | await chai.expect(props).to.have.object(key); 40 | } catch (e) { 41 | received = e; 42 | } 43 | 44 | expect(error).toBe(received); 45 | 46 | expect(verifyProps).toHaveBeenCalledTimes(1); 47 | expect(verifyProps).toHaveBeenCalledWith({ ...props, key }, [ 48 | 'region', 49 | 'bucket', 50 | 'key', 51 | ]); 52 | 53 | expect(getObject).toHaveBeenCalledTimes(1); 54 | expect(getObject).toHaveBeenCalledWith(region, bucket, key); 55 | 56 | expect(wrapWithRetries).toHaveBeenCalledTimes(1); 57 | }); 58 | 59 | test('should pass on have object', async () => { 60 | const { getObject } = require('../utils/s3'); 61 | 62 | getObject.mockReturnValue(Promise.resolve({ found: true })); 63 | 64 | expect.assertions(2); 65 | 66 | // should not throw error on object exists 67 | await chai.expect(props).to.have.object(key); 68 | 69 | try { 70 | // should throw error on no object 71 | getObject.mockReturnValue(Promise.resolve({ found: false })); 72 | await chai.expect(props).to.have.object(key); 73 | } catch (error) { 74 | const e = error as Error; 75 | expect(e).toBeInstanceOf(chai.AssertionError); 76 | expect(e.message).toBe( 77 | `expected ${bucket} to have object with key ${key}`, 78 | ); 79 | } 80 | }); 81 | 82 | test('should pass on not have object', async () => { 83 | const { getObject } = require('../utils/s3'); 84 | 85 | getObject.mockReturnValue(Promise.resolve({ found: false })); 86 | 87 | expect.assertions(2); 88 | 89 | // should not throw error on no object 90 | await chai.expect(props).to.not.have.object(key); 91 | 92 | try { 93 | // should throw error on object exists 94 | getObject.mockReturnValue(Promise.resolve({ found: true })); 95 | await chai.expect(props).to.not.have.object(key); 96 | } catch (error) { 97 | const e = error as Error; 98 | expect(e).toBeInstanceOf(chai.AssertionError); 99 | expect(e.message).toBe( 100 | `expected ${bucket} not to have object with key ${key}`, 101 | ); 102 | } 103 | }); 104 | 105 | test('should pass on have object buffer', async () => { 106 | const { getObject } = require('../utils/s3'); 107 | 108 | getObject.mockReturnValue( 109 | Promise.resolve({ found: true, body: Buffer.from('some object') }), 110 | ); 111 | 112 | expect.assertions(2); 113 | 114 | const expected = Buffer.from('some object'); 115 | // should not throw error on object equals 116 | await chai.expect(props).to.have.object(key, expected); 117 | 118 | const actual = { found: true, body: Buffer.from('other object') }; 119 | try { 120 | // should throw error on object not equals 121 | getObject.mockReturnValue(Promise.resolve(actual)); 122 | await chai.expect(props).to.have.object(key, expected); 123 | } catch (error) { 124 | const e = error as Error; 125 | expect(e).toBeInstanceOf(chai.AssertionError); 126 | expect(e.message).toBe( 127 | `expected ${utils.objDisplay( 128 | expected, 129 | )} to be equal to ${utils.objDisplay(actual.body)}`, 130 | ); 131 | } 132 | }); 133 | 134 | test('should pass on not have object buffer', async () => { 135 | const { getObject } = require('../utils/s3'); 136 | 137 | getObject.mockReturnValue( 138 | Promise.resolve({ found: true, body: Buffer.from('other object') }), 139 | ); 140 | 141 | expect.assertions(2); 142 | 143 | const expected = Buffer.from('some object'); 144 | // should not throw error on object not equals 145 | await chai.expect(props).to.not.have.object(key, expected); 146 | 147 | const actual = { found: true, body: Buffer.from('some object') }; 148 | try { 149 | // should throw error on object equals 150 | getObject.mockReturnValue(Promise.resolve(actual)); 151 | await chai.expect(props).to.not.have.object(key, expected); 152 | } catch (error) { 153 | const e = error as Error; 154 | expect(e).toBeInstanceOf(chai.AssertionError); 155 | expect(e.message).toBe( 156 | `expected ${utils.objDisplay( 157 | expected, 158 | )} to not be equal to ${utils.objDisplay(actual.body)}`, 159 | ); 160 | } 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/chai/s3.ts: -------------------------------------------------------------------------------- 1 | import { verifyProps } from '../common'; 2 | import { expectedProps, IS3Props } from '../common/s3'; 3 | import { getObject } from '../utils/s3'; 4 | import { wrapWithRetries } from './utils'; 5 | 6 | const attemptS3 = async function ( 7 | this: any, 8 | eql: any, 9 | objDisplay: any, 10 | key: string, 11 | expected: Buffer, 12 | ) { 13 | const props = this._obj as IS3Props; 14 | verifyProps({ ...props, key }, expectedProps); 15 | 16 | const { region, bucket } = props; 17 | const { body: received, found } = await getObject(region, bucket, key); 18 | 19 | if (found && expected) { 20 | // check equality as well 21 | const deepEquals = eql(expected, received); 22 | return { 23 | message: `expected ${objDisplay(expected)} to be equal to ${objDisplay( 24 | received, 25 | )}`, 26 | negateMessage: `expected ${objDisplay( 27 | expected, 28 | )} to not be equal to ${objDisplay(received)}`, 29 | pass: deepEquals, 30 | }; 31 | } else { 32 | // only check existence 33 | return { 34 | message: `expected ${bucket} to have object with key ${key}`, 35 | negateMessage: `expected ${bucket} not to have object with key ${key}`, 36 | pass: found, 37 | }; 38 | } 39 | }; 40 | 41 | const s3 = (chai: any, { eql, objDisplay }: any) => { 42 | chai.Assertion.addMethod( 43 | 'object', 44 | async function (this: any, key: string, expected?: Buffer) { 45 | const wrapped = wrapWithRetries(attemptS3); 46 | const { pass, message, negateMessage } = await wrapped.apply(this, [ 47 | eql, 48 | objDisplay, 49 | key, 50 | expected, 51 | ]); 52 | 53 | this.assert(pass, message, negateMessage); 54 | }, 55 | ); 56 | }; 57 | 58 | export default s3; 59 | -------------------------------------------------------------------------------- /src/chai/sqs.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import chai = require('chai'); 3 | import './'; 4 | import s3 from './sqs'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/sqs'); 8 | jest.mock('./utils', () => { 9 | return { wrapWithRetries: jest.fn((f) => f) }; 10 | }); 11 | 12 | chai.use(s3); 13 | 14 | describe('sqs', () => { 15 | describe('message', () => { 16 | const region = 'region'; 17 | const queueUrl = 'queueUrl'; 18 | const props = { region, queueUrl }; 19 | const matcher = jest.fn(); 20 | 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | test('should throw error on existsInQueue error', async () => { 26 | const { verifyProps } = require('../common'); 27 | const { existsInQueue } = require('../utils/sqs'); 28 | const { wrapWithRetries } = require('./utils'); 29 | 30 | const error = new Error('Unknown error'); 31 | existsInQueue.mockReturnValue(Promise.reject(error)); 32 | 33 | expect.assertions(6); 34 | 35 | let received = null; 36 | try { 37 | await chai.expect(props).to.have.message(matcher); 38 | } catch (e) { 39 | received = e; 40 | } 41 | 42 | expect(error).toBe(received); 43 | 44 | expect(verifyProps).toHaveBeenCalledTimes(1); 45 | expect(verifyProps).toHaveBeenCalledWith({ ...props, matcher }, [ 46 | 'region', 47 | 'queueUrl', 48 | 'matcher', 49 | ]); 50 | 51 | expect(existsInQueue).toHaveBeenCalledTimes(1); 52 | expect(existsInQueue).toHaveBeenCalledWith(region, queueUrl, matcher); 53 | 54 | expect(wrapWithRetries).toHaveBeenCalledTimes(1); 55 | }); 56 | 57 | test('should pass on have message', async () => { 58 | const { existsInQueue } = require('../utils/sqs'); 59 | 60 | existsInQueue.mockReturnValue(Promise.resolve(true)); 61 | 62 | expect.assertions(2); 63 | 64 | // should not throw error on message exists 65 | await chai.expect(props).to.have.message(matcher); 66 | 67 | try { 68 | // should throw error on no message 69 | existsInQueue.mockReturnValue(Promise.resolve(false)); 70 | await chai.expect(props).to.have.message(matcher); 71 | } catch (error) { 72 | const e = error as Error; 73 | expect(e).toBeInstanceOf(chai.AssertionError); 74 | expect(e.message).toBe(`expected ${queueUrl} to have message`); 75 | } 76 | }); 77 | 78 | test('should pass on not have record', async () => { 79 | const { existsInQueue } = require('../utils/sqs'); 80 | 81 | existsInQueue.mockReturnValue(Promise.resolve(false)); 82 | 83 | expect.assertions(2); 84 | 85 | // should not throw error on no message 86 | await chai.expect(props).to.not.have.message(matcher); 87 | 88 | try { 89 | // should throw error on message exists 90 | existsInQueue.mockReturnValue(Promise.resolve(true)); 91 | await chai.expect(props).to.not.have.message(matcher); 92 | } catch (error) { 93 | const e = error as Error; 94 | expect(e).toBeInstanceOf(chai.AssertionError); 95 | expect(e.message).toBe(`expected ${queueUrl} not to have message`); 96 | } 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/chai/sqs.ts: -------------------------------------------------------------------------------- 1 | import { verifyProps } from '../common'; 2 | import { expectedProps, ISqsProps } from '../common/sqs'; 3 | import { existsInQueue, IMessageMatcher } from '../utils/sqs'; 4 | import { wrapWithRetries } from './utils'; 5 | 6 | declare global { 7 | namespace Chai { 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | interface Assertion { 10 | message: (matcher: IMessageMatcher) => Assertion; 11 | } 12 | } 13 | } 14 | 15 | const attemptSqs = async function (this: any, matcher: IMessageMatcher) { 16 | const props = this._obj as ISqsProps; 17 | verifyProps({ ...props, matcher }, expectedProps); 18 | 19 | const { region, queueUrl } = props; 20 | 21 | const found = await existsInQueue(region, queueUrl, matcher); 22 | return { 23 | message: `expected ${queueUrl} to have message`, 24 | negateMessage: `expected ${queueUrl} not to have message`, 25 | pass: found, 26 | }; 27 | }; 28 | 29 | const sqs = (chai: any) => { 30 | chai.Assertion.addMethod( 31 | 'message', 32 | async function (this: any, matcher: IMessageMatcher) { 33 | const wrapped = wrapWithRetries(attemptSqs); 34 | const { pass, message, negateMessage } = await wrapped.apply(this, [ 35 | matcher, 36 | ]); 37 | 38 | this.assert(pass, message, negateMessage); 39 | }, 40 | ); 41 | }; 42 | 43 | export default sqs; 44 | -------------------------------------------------------------------------------- /src/chai/stepFunctions.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import chai = require('chai'); 3 | import './'; 4 | import stepFunctions from './stepFunctions'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/stepFunctions'); 8 | jest.mock('./utils', () => { 9 | return { wrapWithRetries: jest.fn((f) => f) }; 10 | }); 11 | 12 | chai.use(stepFunctions); 13 | 14 | describe('stepFunctions', () => { 15 | const region = 'region'; 16 | const stateMachineArn = 'stateMachineArn'; 17 | const props = { region, stateMachineArn }; 18 | const state = 'expectedState'; 19 | 20 | describe('atState', () => { 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | test('should throw error on getCurrentState error', async () => { 26 | const { verifyProps } = require('../common'); 27 | const { getCurrentState } = require('../utils/stepFunctions'); 28 | const { wrapWithRetries } = require('./utils'); 29 | 30 | const error = new Error('Unknown error'); 31 | getCurrentState.mockReturnValue(Promise.reject(error)); 32 | 33 | expect.assertions(6); 34 | 35 | let received = null; 36 | try { 37 | await chai.expect(props).to.be.atState(state); 38 | } catch (e) { 39 | received = e; 40 | } 41 | 42 | expect(error).toBe(received); 43 | 44 | expect(verifyProps).toHaveBeenCalledTimes(1); 45 | expect(verifyProps).toHaveBeenCalledWith({ ...props, state }, [ 46 | 'region', 47 | 'stateMachineArn', 48 | 'state', 49 | ]); 50 | 51 | expect(getCurrentState).toHaveBeenCalledTimes(1); 52 | expect(getCurrentState).toHaveBeenCalledWith(region, stateMachineArn); 53 | 54 | expect(wrapWithRetries).toHaveBeenCalledTimes(1); 55 | }); 56 | 57 | test('should pass on be at state', async () => { 58 | const { getCurrentState } = require('../utils/stepFunctions'); 59 | 60 | getCurrentState.mockReturnValue(Promise.resolve(state)); 61 | 62 | expect.assertions(2); 63 | 64 | // should not throw error on state exists 65 | await chai.expect(props).to.be.atState(state); 66 | 67 | try { 68 | // should throw error on state not found 69 | getCurrentState.mockReturnValue(Promise.resolve('other state')); 70 | await chai.expect(props).to.be.atState(state); 71 | } catch (error) { 72 | const e = error as Error; 73 | expect(e).toBeInstanceOf(chai.AssertionError); 74 | expect(e.message).toBe( 75 | `expected ${stateMachineArn} to be at state ${state}`, 76 | ); 77 | } 78 | }); 79 | 80 | test('should pass on not be at state', async () => { 81 | const { getCurrentState } = require('../utils/stepFunctions'); 82 | 83 | getCurrentState.mockReturnValue(Promise.resolve('other state')); 84 | 85 | expect.assertions(2); 86 | 87 | // should not throw error on state not found 88 | await chai.expect(props).to.not.be.atState(state); 89 | 90 | try { 91 | // should throw error on state exists 92 | getCurrentState.mockReturnValue(Promise.resolve(state)); 93 | await chai.expect(props).to.not.be.atState(state); 94 | } catch (error) { 95 | const e = error as Error; 96 | expect(e).toBeInstanceOf(chai.AssertionError); 97 | expect(e.message).toBe( 98 | `expected ${stateMachineArn} not to be at state ${state}`, 99 | ); 100 | } 101 | }); 102 | }); 103 | 104 | describe('state', () => { 105 | beforeEach(() => { 106 | jest.clearAllMocks(); 107 | }); 108 | 109 | test('should throw error on getStates error', async () => { 110 | const { verifyProps } = require('../common'); 111 | const { getStates } = require('../utils/stepFunctions'); 112 | 113 | const error = new Error('Unknown error'); 114 | getStates.mockReturnValue(Promise.reject(error)); 115 | 116 | expect.assertions(5); 117 | 118 | let received = null; 119 | try { 120 | await chai.expect(props).to.have.state(state); 121 | } catch (e) { 122 | received = e; 123 | } 124 | 125 | expect(error).toBe(received); 126 | 127 | expect(verifyProps).toHaveBeenCalledTimes(1); 128 | expect(verifyProps).toHaveBeenCalledWith({ ...props, state }, [ 129 | 'region', 130 | 'stateMachineArn', 131 | 'state', 132 | ]); 133 | 134 | expect(getStates).toHaveBeenCalledTimes(1); 135 | expect(getStates).toHaveBeenCalledWith(region, stateMachineArn); 136 | }); 137 | 138 | test('should pass on have state', async () => { 139 | const { getStates } = require('../utils/stepFunctions'); 140 | 141 | getStates.mockReturnValue(Promise.resolve([state])); 142 | 143 | expect.assertions(2); 144 | 145 | // should not throw error on state exists 146 | await chai.expect(props).to.have.state(state); 147 | 148 | try { 149 | // should throw error on state not found 150 | getStates.mockReturnValue(Promise.resolve([])); 151 | await chai.expect(props).to.have.state(state); 152 | } catch (error) { 153 | const e = error as Error; 154 | expect(e).toBeInstanceOf(chai.AssertionError); 155 | expect(e.message).toBe( 156 | `expected ${stateMachineArn} to have state ${state}`, 157 | ); 158 | } 159 | }); 160 | 161 | test('should pass on not have state', async () => { 162 | const { getStates } = require('../utils/stepFunctions'); 163 | 164 | getStates.mockReturnValue(Promise.resolve([])); 165 | 166 | expect.assertions(2); 167 | 168 | // should not throw error on state not found 169 | await chai.expect(props).to.not.have.state(state); 170 | 171 | try { 172 | // should throw error on state exists 173 | getStates.mockReturnValue(Promise.resolve([state])); 174 | await chai.expect(props).to.not.have.state(state); 175 | } catch (error) { 176 | const e = error as Error; 177 | expect(e).toBeInstanceOf(chai.AssertionError); 178 | expect(e.message).toBe( 179 | `expected ${stateMachineArn} not to have state ${state}`, 180 | ); 181 | } 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /src/chai/stepFunctions.ts: -------------------------------------------------------------------------------- 1 | import { verifyProps } from '../common'; 2 | import { expectedProps, IStepFunctionsProps } from '../common/stepFunctions'; 3 | import { getCurrentState, getStates } from '../utils/stepFunctions'; 4 | import { wrapWithRetries } from './utils'; 5 | 6 | declare global { 7 | namespace Chai { 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | interface Assertion { 10 | atState: (state: string) => Assertion; 11 | state: (state: string) => Assertion; 12 | } 13 | } 14 | } 15 | 16 | const attemptAtState = async function (this: any, state: string) { 17 | const props = this._obj as IStepFunctionsProps; 18 | verifyProps({ ...props, state }, expectedProps); 19 | 20 | const { region, stateMachineArn } = props; 21 | 22 | const received = await getCurrentState(region, stateMachineArn); 23 | const pass = received === state; 24 | 25 | return { 26 | message: `expected ${stateMachineArn} to be at state ${state}`, 27 | negateMessage: `expected ${stateMachineArn} not to be at state ${state}`, 28 | pass, 29 | }; 30 | }; 31 | 32 | const attemptHaveState = async function (this: any, state: string) { 33 | const props = this._obj as IStepFunctionsProps; 34 | verifyProps({ ...props, state }, expectedProps); 35 | 36 | const { region, stateMachineArn } = props; 37 | 38 | const states = await getStates(region, stateMachineArn); 39 | const pass = states.includes(state); 40 | 41 | return { 42 | message: `expected ${stateMachineArn} to have state ${state}`, 43 | negateMessage: `expected ${stateMachineArn} not to have state ${state}`, 44 | pass, 45 | }; 46 | }; 47 | 48 | const stepFunctions = (chai: any) => { 49 | chai.Assertion.addMethod( 50 | 'atState', 51 | async function (this: any, state: string) { 52 | const wrapped = wrapWithRetries(attemptAtState); 53 | const { pass, message, negateMessage } = await wrapped.apply(this, [ 54 | state, 55 | ]); 56 | 57 | this.assert(pass, message, negateMessage); 58 | }, 59 | ); 60 | 61 | chai.Assertion.addMethod('state', async function (this: any, state: string) { 62 | const wrapped = wrapWithRetries(attemptHaveState); 63 | const { pass, message, negateMessage } = await wrapped.apply(this, [state]); 64 | 65 | this.assert(pass, message, negateMessage); 66 | }); 67 | }; 68 | 69 | export default stepFunctions; 70 | -------------------------------------------------------------------------------- /src/chai/utils.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { ICommonProps } from '../common'; 3 | import { wrapWithRetries } from './utils'; 4 | 5 | jest.mock('../common'); 6 | 7 | describe('utils', () => { 8 | describe('wrapWithRetries', () => { 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | let arr = [ 14 | { pass: true, negate: false }, 15 | { pass: false, negate: true }, 16 | ]; 17 | arr.forEach(({ pass, negate }) => { 18 | test(`should retry once on pass === ${pass}, negate === ${negate}`, async () => { 19 | const toWrap = jest.fn(); 20 | const expectedResult = { pass, message: () => '' }; 21 | toWrap.mockReturnValue(Promise.resolve(expectedResult)); 22 | 23 | const props = { region: 'region' } as ICommonProps; 24 | const context = { 25 | __flags: { negate }, 26 | _obj: props, 27 | } as any; 28 | 29 | const key = 'key'; 30 | 31 | const wrapped = wrapWithRetries(toWrap); 32 | const result = await wrapped.bind(context)(key); 33 | 34 | expect(toWrap).toHaveBeenCalledTimes(1); 35 | expect(toWrap).toHaveBeenCalledWith(key); 36 | expect(result).toBe(expectedResult); 37 | }); 38 | }); 39 | 40 | arr = [ 41 | { pass: false, negate: false }, 42 | { pass: true, negate: true }, 43 | ]; 44 | arr.forEach(({ pass, negate }) => { 45 | test(`should exhaust timeout on pass === ${pass}, negate === ${negate}`, async () => { 46 | const { sleep } = require('../common'); 47 | 48 | const mockedNow = jest.fn(); 49 | Date.now = mockedNow; 50 | mockedNow.mockReturnValueOnce(0); 51 | mockedNow.mockReturnValueOnce(250); 52 | mockedNow.mockReturnValueOnce(500); 53 | mockedNow.mockReturnValueOnce(750); 54 | mockedNow.mockReturnValueOnce(1000); 55 | mockedNow.mockReturnValueOnce(1250); 56 | 57 | const toWrap = jest.fn(); 58 | const expectedResult = { pass, message: () => '' }; 59 | toWrap.mockReturnValue(Promise.resolve(expectedResult)); 60 | 61 | const props = { timeout: 1001, pollEvery: 250 } as ICommonProps; 62 | const context = { 63 | __flags: { negate }, 64 | _obj: props, 65 | } as any; 66 | 67 | const key = 'key'; 68 | 69 | const wrapped = wrapWithRetries(toWrap); 70 | const result = await wrapped.bind(context)(key); 71 | 72 | expect(toWrap).toHaveBeenCalledTimes(5); 73 | expect(toWrap).toHaveBeenCalledWith(key); 74 | expect(result).toBe(expectedResult); 75 | expect(sleep).toHaveBeenCalledTimes(4); 76 | expect(sleep).toHaveBeenCalledWith(props.pollEvery); 77 | }); 78 | }); 79 | 80 | test('should retry twice, { pass: false, isNot: false } => { pass: true, isNot: false }', async () => { 81 | const { sleep } = require('../common'); 82 | 83 | const mockedNow = jest.fn(); 84 | Date.now = mockedNow; 85 | mockedNow.mockReturnValueOnce(0); 86 | mockedNow.mockReturnValueOnce(250); 87 | mockedNow.mockReturnValueOnce(500); 88 | 89 | const toWrap = jest.fn(); 90 | // first attempt returns pass === false 91 | toWrap.mockReturnValueOnce( 92 | Promise.resolve({ pass: false, message: () => '' }), 93 | ); 94 | 95 | // second attempt returns pass === true 96 | const expectedResult = { pass: true, message: () => '' }; 97 | toWrap.mockReturnValueOnce(Promise.resolve(expectedResult)); 98 | 99 | const props = {} as ICommonProps; 100 | const context = { 101 | __flags: { negate: false }, 102 | _obj: props, 103 | } as any; 104 | 105 | const key = 'key'; 106 | 107 | const wrapped = wrapWithRetries(toWrap); 108 | const result = await wrapped.bind(context)(key); 109 | 110 | expect(toWrap).toHaveBeenCalledTimes(2); 111 | expect(toWrap).toHaveBeenCalledWith(key); 112 | expect(result).toBe(expectedResult); 113 | expect(sleep).toHaveBeenCalledTimes(1); 114 | expect(sleep).toHaveBeenCalledWith(500); // default pollEvery 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/chai/utils.ts: -------------------------------------------------------------------------------- 1 | import { ICommonProps, sleep } from '../common'; 2 | 3 | interface IMatchResult { 4 | pass: boolean; 5 | message: string; 6 | negateMessage: string; 7 | } 8 | 9 | export const wrapWithRetries = ( 10 | matcher: (...args: any[]) => Promise, 11 | ) => { 12 | async function wrapped(this: any, ...args: any[]) { 13 | const props = this._obj as ICommonProps; 14 | const { negate } = this.__flags; 15 | 16 | const { timeout = 2500, pollEvery = 500 } = props; 17 | 18 | const start = Date.now(); 19 | let result = await (matcher.apply(this, args) as Promise); 20 | while (Date.now() - start < timeout) { 21 | // expecting pass === false 22 | if (negate && !result.pass) { 23 | return result; 24 | } 25 | // expecting pass === true 26 | if (!negate && result.pass) { 27 | return result; 28 | } 29 | 30 | // retry 31 | await sleep(pollEvery); 32 | 33 | result = await (matcher.apply(this, args) as Promise); 34 | } 35 | return result; 36 | } 37 | return wrapped; 38 | }; 39 | -------------------------------------------------------------------------------- /src/common/api.ts: -------------------------------------------------------------------------------- 1 | import { Method } from 'axios'; 2 | import { PlainObject } from '../utils/api'; 3 | 4 | export interface IApiProps { 5 | method: Method; 6 | url: string; 7 | params?: PlainObject; 8 | data?: PlainObject; 9 | headers?: PlainObject; 10 | } 11 | 12 | export interface IExpectedResponse { 13 | statusCode: number; 14 | data: PlainObject; 15 | } 16 | 17 | export const expectedProps = ['url', 'method']; 18 | -------------------------------------------------------------------------------- /src/common/cloudwatch.ts: -------------------------------------------------------------------------------- 1 | import { ICommonProps } from './'; 2 | 3 | export interface ICloudwatchProps extends ICommonProps { 4 | function?: string; 5 | startTime?: number; 6 | logGroupName?: string; 7 | } 8 | 9 | export const expectedProps = ['region', 'pattern']; 10 | -------------------------------------------------------------------------------- /src/common/dynamoDb.test.ts: -------------------------------------------------------------------------------- 1 | import { removeKeysFromItemForNonStrictComparison } from './dynamoDb'; 2 | 3 | describe('common dynamoDb', () => { 4 | test('removeKeysFromItemForNonStrictComparison should remove keys not in expected', () => { 5 | const received = { 6 | name: { S: 'hello' }, 7 | date: { S: new Date().toISOString() }, 8 | }; 9 | const expected = { 10 | name: { S: 'hello' }, 11 | }; 12 | 13 | const removed = removeKeysFromItemForNonStrictComparison( 14 | received, 15 | expected, 16 | ); 17 | expect(removed).toEqual({ 18 | name: { S: 'hello' }, 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/common/dynamoDb.ts: -------------------------------------------------------------------------------- 1 | import filterObject from 'filter-obj'; 2 | import { AttributeMap } from 'aws-sdk/clients/dynamodb'; 3 | import { ICommonProps } from './'; 4 | 5 | export interface IDynamoDbProps extends ICommonProps { 6 | table: string; 7 | } 8 | 9 | export const expectedProps = ['region', 'table', 'key']; 10 | 11 | export const removeKeysFromItemForNonStrictComparison = ( 12 | received: AttributeMap, 13 | expected: AttributeMap, 14 | ) => { 15 | return filterObject(received, (key) => 16 | Object.prototype.hasOwnProperty.call(expected, key), 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/common/index.test.ts: -------------------------------------------------------------------------------- 1 | import { epochDateMinusHours, sleep, verifyProps } from './'; 2 | 3 | describe('common index', () => { 4 | afterEach(() => { 5 | jest.useRealTimers(); 6 | }); 7 | 8 | describe('sleep', () => { 9 | test('should call setTimeout', async () => { 10 | jest.useFakeTimers(); 11 | jest.spyOn(global, 'setTimeout'); 12 | const promise = sleep(1000); 13 | jest.runAllTimers(); 14 | 15 | await promise; 16 | 17 | expect(setTimeout).toHaveBeenCalledTimes(1); 18 | expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); 19 | }); 20 | }); 21 | 22 | describe('verifyProps', () => { 23 | test('should throw error on missing prop', () => { 24 | expect(() => verifyProps({}, ['id'])).toThrowError( 25 | new Error('Missing id from received props'), 26 | ); 27 | }); 28 | 29 | test('should not throw error on no missing prop', () => { 30 | expect(() => verifyProps({ id: 'value' }, ['id'])).not.toThrow(); 31 | }); 32 | }); 33 | 34 | describe('epochDateMinusHours', () => { 35 | jest.spyOn(Date, 'now').mockImplementation(() => 12 * 60 * 60 * 1000); 36 | 37 | test('test implementation', () => { 38 | const actual = epochDateMinusHours(1); 39 | 40 | expect(actual).toEqual(11 * 60 * 60 * 1000); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export interface ICommonProps { 2 | region: string; 3 | timeout?: number; 4 | pollEvery?: number; 5 | } 6 | 7 | export const sleep = async (ms: number) => { 8 | return await new Promise((resolve) => setTimeout(resolve, ms)); 9 | }; 10 | 11 | export const verifyProps = (props: any, expectedProps: string[]) => { 12 | for (const prop of expectedProps) { 13 | const value = props[prop]; 14 | if (!value) { 15 | throw new Error(`Missing ${prop} from received props`); 16 | } 17 | } 18 | }; 19 | 20 | const hoursToMilliseconds = (hours: number) => hours * 60 * 60 * 1000; 21 | 22 | export const epochDateMinusHours = (hours: number) => { 23 | return Date.now() - hoursToMilliseconds(hours); 24 | }; 25 | -------------------------------------------------------------------------------- /src/common/kinesis.ts: -------------------------------------------------------------------------------- 1 | import { ICommonProps } from './'; 2 | 3 | export interface IKinesisProps extends ICommonProps { 4 | stream: string; 5 | } 6 | 7 | export const expectedProps = ['region', 'stream', 'matcher']; 8 | -------------------------------------------------------------------------------- /src/common/s3.ts: -------------------------------------------------------------------------------- 1 | import { ICommonProps } from './'; 2 | 3 | export interface IS3Props extends ICommonProps { 4 | bucket: string; 5 | } 6 | 7 | export const expectedProps = ['region', 'bucket', 'key']; 8 | -------------------------------------------------------------------------------- /src/common/sqs.ts: -------------------------------------------------------------------------------- 1 | import { ICommonProps } from './'; 2 | 3 | export interface ISqsProps extends ICommonProps { 4 | queueUrl: string; 5 | } 6 | 7 | export const expectedProps = ['region', 'queueUrl', 'matcher']; 8 | -------------------------------------------------------------------------------- /src/common/stepFunctions.ts: -------------------------------------------------------------------------------- 1 | import { ICommonProps } from './'; 2 | 3 | export interface IStepFunctionsProps extends ICommonProps { 4 | stateMachineArn: string; 5 | } 6 | 7 | export const expectedProps = ['region', 'stateMachineArn', 'state']; 8 | -------------------------------------------------------------------------------- /src/jest/README.md: -------------------------------------------------------------------------------- 1 | # Jest Setup 2 | 3 | The simplest setup is to use jest's `setupFilesAfterEnv` config. 4 | 5 | Make sure your `package.json` includes the following: 6 | 7 | ```json 8 | // package.json 9 | "jest": { 10 | "setupFilesAfterEnv": ["./node_modules/aws-testing-library/lib/jest/index.js"], 11 | }, 12 | ``` 13 | 14 | ## Usage with TypeScript 15 | 16 | When using `aws-testing-library` with [TypeScript](http://typescriptlang.org/) and [ts-jest](https://github.com/kulshekhar/ts-jest), you'll need to add a `setupFrameworks.ts` file to your app that explicitly imports `aws-testing-library`, and point the `setupFilesAfterEnv` field in your `package.json` file towards it: 17 | 18 | ```typescript 19 | // src/setupFrameworks.ts 20 | import 'aws-testing-library/lib/jest'; 21 | ``` 22 | 23 | ```json 24 | // package.json 25 | "jest": { 26 | "setupFilesAfterEnv": ["./src/setupFrameworks.ts"], 27 | }, 28 | ``` 29 | 30 | ## Assertions 31 | 32 | > Notes 33 | > 34 | > - The matchers use `aws-sdk` under the hood, thus they are all asynchronous and require using `async/await` 35 | 36 | - [toHaveItem()](#tohaveitem) 37 | - [toHaveObject()](#tohaveobject) 38 | - [toHaveLog()](#tohavelog) 39 | - [toBeAtState()](#tobeatstate) 40 | - [toHaveState()](#tohavestate) 41 | - [toReturnResponse()](#toreturnresponse) 42 | - [toHaveRecord()](#tohaverecord) 43 | - [toHaveMessage()](#tohavemessage) 44 | 45 | ### `toHaveItem()` 46 | 47 | Asserts existence/equality of a DynamoDb item 48 | 49 | ```js 50 | await expect({ 51 | region: 'us-east-1', 52 | table: 'dynamo-db-table', 53 | timeout: 0 /* optional (defaults to 2500) */, 54 | pollEvery: 0 /* optional (defaults to 500) */, 55 | }).toHaveItem( 56 | { 57 | id: 'itemId', 58 | } /* dynamoDb key object (https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#getItem-property) */, 59 | { 60 | id: 'itemId', 61 | createdAt: new Date().getTime(), 62 | text: 'some content', 63 | } /* optional, if exists will check equality in addition to existence */, 64 | true /* optional, strict mode comparison, defaults to true */, 65 | ); 66 | ``` 67 | 68 | [See complete example](https://github.com/erezrokah/serverless-monorepo-app/blob/master/services/db-service/e2e/db.test.ts) 69 | 70 | ### `toHaveObject()` 71 | 72 | Asserts existence/equality of a S3 object 73 | 74 | ```js 75 | await expect({ 76 | region: 'us-east-1', 77 | bucket: 's3-bucket', 78 | timeout: 0 /* optional (defaults to 2500) */, 79 | pollEvery: 0 /* optional (defaults to 500) */, 80 | }).toHaveObject( 81 | 'someFileInTheBucket' /* a string representing the object key in the bucket */, 82 | Buffer.from( 83 | 'a buffer of the file content', 84 | ) /* optional, if exists will check equality in addition to existence */, 85 | ); 86 | ``` 87 | 88 | [See complete example](https://github.com/erezrokah/serverless-monorepo-app/blob/master/services/file-service/e2e/handler.test.ts) 89 | 90 | ### `toHaveLog()` 91 | 92 | Asserts existence of a cloudwatch log message 93 | 94 | ```js 95 | await expect({ 96 | region: 'us-east-1', 97 | // use either an explicit log group 98 | logGroupName: 'logGroupName', 99 | // or a function name to match a lambda function logs 100 | function: 'functionName', 101 | startTime: 0 /* optional (milliseconds since epoch in UTC, defaults to now-1 hour) */, 102 | timeout: 0 /* optional (defaults to 2500) */, 103 | pollEvery: 0 /* optional (defaults to 500) */, 104 | }).toHaveLog( 105 | 'some message written to log' /* a pattern to match against log messages */, 106 | ); 107 | ``` 108 | 109 | [See complete example](https://github.com/erezrokah/hello-retail/blob/master/e2eTests/src/sendUserLogin.test.ts) 110 | 111 | ### `toBeAtState()` 112 | 113 | Asserts a state machine current state 114 | 115 | ```js 116 | await expect({ 117 | pollEvery: 5000 /* optional (defaults to 500) */, 118 | region: 'us-east-1', 119 | stateMachineArn: 'stateMachineArn', 120 | timeout: 30 * 1000 /* optional (defaults to 2500) */, 121 | }).toBeAtState('ExpectedState'); 122 | ``` 123 | 124 | [See complete example](https://github.com/erezrokah/hello-retail/blob/master/e2eTests/src/newProduct.test.ts#L73) 125 | 126 | ### `toHaveState()` 127 | 128 | Asserts that a state machine has been at a state 129 | 130 | ```js 131 | await expect({ 132 | pollEvery: 5000 /* optional (defaults to 500) */, 133 | region: 'us-east-1', 134 | stateMachineArn: 'stateMachineArn', 135 | timeout: 30 * 1000 /* optional (defaults to 2500) */, 136 | }).toHaveState('ExpectedState'); 137 | ``` 138 | 139 | [See complete example](https://github.com/erezrokah/hello-retail/blob/master/e2eTests/src/stateMachine.test.ts#L97) 140 | 141 | ### `toReturnResponse()` 142 | 143 | Asserts that an api returns a specific response 144 | 145 | ```js 146 | await expect({ 147 | url: 'https://api-id.execute-api.us-east-1.amazonaws.com/dev/api/private', 148 | method: 'POST', 149 | params: { urlParam: 'value' } /* optional URL parameters */, 150 | data: { bodyParam: 'value' } /* optional body parameters */, 151 | headers: { Authorization: 'Bearer token_value' } /* optional headers */, 152 | }).toReturnResponse({ 153 | data: { 154 | message: 'Unauthorized', 155 | }, 156 | statusCode: 401, 157 | }); 158 | ``` 159 | 160 | [See complete example](https://github.com/erezrokah/serverless-monorepo-app/blob/master/services/api-service/e2e/privateEndpoint.test.ts#L8) 161 | 162 | ### `toHaveRecord()` 163 | 164 | Asserts existence/equality of a Kinesis record 165 | 166 | ```js 167 | await expect({ 168 | region: 'us-east-1', 169 | stream: 'kinesis-stream', 170 | timeout: 0 /* optional (defaults to 10000) */, 171 | pollEvery: 0 /* optional (defaults to 500) */, 172 | }).toHaveRecord( 173 | (item) => item.id === 'someId' /* predicate to match with the stream data */, 174 | ); 175 | ``` 176 | 177 | [See complete example](https://github.com/erezrokah/serverless-monorepo-app/blob/master/services/kinesis-service/e2e/handler.test.ts) 178 | 179 | ### `toHaveMessage()` 180 | 181 | Asserts existence/equality of a message in an SQS queue 182 | 183 | ```js 184 | const { 185 | subscribeToTopic, 186 | unsubscribeFromTopic, 187 | } = require('aws-testing-library/lib/utils/sqs'); 188 | 189 | let [subscriptionArn, queueUrl] = ['', '']; 190 | try { 191 | // create an SQS queue and subscribe to SNS topic 192 | ({ subscriptionArn, queueUrl } = await subscribeToTopic(region, topicArn)); 193 | 194 | // run some code that will publish a message to the SNS topic 195 | someCodeThatResultsInPublishingAMessage(); 196 | 197 | await expect({ 198 | region, 199 | queueUrl, 200 | timeout: 10000 /* optional (defaults to 2500) */, 201 | pollEvery: 2500 /* optional (defaults to 500) */, 202 | }).toHaveMessage( 203 | /* predicate to match with the messages in the queue */ 204 | (message) => 205 | message.Subject === 'Some Subject' && message.Message === 'Some Message', 206 | ); 207 | } finally { 208 | // unsubscribe from SNS topic and delete SQS queue 209 | await unsubscribeFromTopic(region, subscriptionArn, queueUrl); 210 | } 211 | ``` 212 | 213 | [See complete example](https://github.com/erezrokah/serverless-monitoring-app/blob/master/services/monitoring-tester-service/e2e/checkEndpointStepFunction.test.ts) 214 | -------------------------------------------------------------------------------- /src/jest/api.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { Method } from 'axios'; 3 | import * as originalUtils from 'jest-matcher-utils'; 4 | import { EOL } from 'os'; 5 | import { toReturnResponse } from './api'; 6 | 7 | jest.mock('../common'); 8 | jest.mock('../utils/api'); 9 | jest.spyOn(console, 'error'); 10 | jest.mock('jest-diff'); 11 | 12 | describe('api matchers', () => { 13 | describe('toReturnResponse', () => { 14 | const matcherUtils = { 15 | equals: jest.fn(), 16 | expand: true, 17 | isNot: false, 18 | utils: { 19 | ...originalUtils, 20 | diff: jest.fn() as unknown, 21 | getType: jest.fn(), 22 | matcherHint: jest.fn((i) => i), 23 | printExpected: jest.fn((i) => i), 24 | printReceived: jest.fn((i) => i), 25 | }, 26 | } as unknown as jest.MatcherUtils & { equals: jest.Mock }; 27 | const url = 'url'; 28 | const method = 'POST' as Method; 29 | const params = { param1: 'param1' }; 30 | const data = { data1: 'data1' }; 31 | const headers = { header1: 'header1' }; 32 | 33 | const props = { url, method, params, data, headers }; 34 | 35 | beforeEach(() => { 36 | jest.clearAllMocks(); 37 | }); 38 | 39 | test('should throw error on getResponse error', async () => { 40 | const { verifyProps } = require('../common'); 41 | const { getResponse } = require('../utils/api'); 42 | 43 | const error = new Error('Unknown error'); 44 | getResponse.mockReturnValue(Promise.reject(error)); 45 | 46 | const expected = { statusCode: 200, data: { id: 'id' } }; 47 | 48 | expect.assertions(7); 49 | await expect( 50 | toReturnResponse.bind(matcherUtils)(props, expected), 51 | ).rejects.toBe(error); 52 | 53 | expect(getResponse).toHaveBeenCalledTimes(1); 54 | expect(getResponse).toHaveBeenCalledWith( 55 | url, 56 | method, 57 | params, 58 | data, 59 | headers, 60 | ); 61 | expect(console.error).toHaveBeenCalledTimes(1); 62 | expect(console.error).toHaveBeenCalledWith( 63 | `Unknown error while getting response: ${error.message}`, 64 | ); 65 | expect(verifyProps).toHaveBeenCalledTimes(1); 66 | expect(verifyProps).toHaveBeenCalledWith({ ...props }, ['url', 'method']); 67 | }); 68 | 69 | test('should not pass on response not matching', async () => { 70 | const { diff } = require('jest-diff'); 71 | const diffString = 'diffString'; 72 | diff.mockReturnValue(diffString); 73 | 74 | matcherUtils.equals.mockReturnValue(false); 75 | 76 | const { getResponse } = require('../utils/api'); 77 | 78 | const received = { statusCode: 200, data: { id: 'someItem' } }; 79 | getResponse.mockReturnValue(Promise.resolve(received)); 80 | 81 | const expected = { statusCode: 200, data: { id: 'otherItem' } }; 82 | const { message, pass } = await toReturnResponse.bind(matcherUtils)( 83 | props, 84 | expected, 85 | ); 86 | 87 | expect(pass).toBeFalsy(); 88 | expect(message).toEqual(expect.any(Function)); 89 | expect(message()).toEqual( 90 | `.toReturnResponse${EOL}${EOL}Expected response ${received} to equal ${expected}${EOL}Difference:${EOL}${EOL}${diffString}${EOL}Request data: ${method}: ${url}`, 91 | ); 92 | expect(matcherUtils.equals).toHaveBeenCalledTimes(1); 93 | expect(matcherUtils.equals).toHaveBeenCalledWith(expected, received); 94 | expect(diff).toHaveBeenCalledTimes(1); 95 | expect(diff).toHaveBeenCalledWith(expected, received, { 96 | expand: true, 97 | }); 98 | }); 99 | 100 | test('should not pass on getItem item not matching empty diffString', async () => { 101 | const { diff } = require('jest-diff'); 102 | const diffString = ''; 103 | diff.mockReturnValue(diffString); 104 | 105 | matcherUtils.equals.mockReturnValue(false); 106 | 107 | const { getResponse } = require('../utils/api'); 108 | 109 | const received = { 110 | data: { message: 'error' }, 111 | statusCode: 500, 112 | }; 113 | getResponse.mockReturnValue(Promise.resolve(received)); 114 | 115 | const expected = { 116 | data: { message: 'hello' }, 117 | statusCode: 200, 118 | }; 119 | const { message, pass } = await toReturnResponse.bind(matcherUtils)( 120 | props, 121 | expected, 122 | ); 123 | 124 | expect(pass).toBeFalsy(); 125 | expect(message()).toEqual( 126 | `.toReturnResponse${EOL}${EOL}Expected response ${received} to equal ${expected}${EOL}Difference:${EOL}Request data: ${method}: ${url}`, 127 | ); 128 | }); 129 | 130 | test('should pass on getItem item matching', async () => { 131 | const { diff } = require('jest-diff'); 132 | 133 | matcherUtils.equals.mockReturnValue(true); 134 | 135 | const { getResponse } = require('../utils/api'); 136 | 137 | const received = { 138 | data: { message: 'hello' }, 139 | statusCode: 200, 140 | }; 141 | getResponse.mockReturnValue(Promise.resolve(received)); 142 | 143 | const expected = { 144 | data: { message: 'hello' }, 145 | statusCode: 200, 146 | }; 147 | const { message, pass } = await toReturnResponse.bind(matcherUtils)( 148 | props, 149 | expected, 150 | ); 151 | 152 | expect(pass).toBeTruthy(); 153 | expect(message).toEqual(expect.any(Function)); 154 | expect(message()).toEqual( 155 | `.not.toReturnResponse${EOL}${EOL}Expected response ${received} not to equal ${expected}.${EOL}Request data: ${method}: ${url}`, 156 | ); 157 | expect(matcherUtils.equals).toHaveBeenCalledTimes(1); 158 | expect(matcherUtils.equals).toHaveBeenCalledWith(expected, received); 159 | expect(diff).toHaveBeenCalledTimes(0); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/jest/api.ts: -------------------------------------------------------------------------------- 1 | import { diff } from 'jest-diff'; 2 | import { EOL } from 'os'; 3 | import { verifyProps } from '../common'; 4 | import { expectedProps, IApiProps, IExpectedResponse } from '../common/api'; 5 | import { getResponse } from '../utils/api'; 6 | 7 | export const toReturnResponse = async function ( 8 | this: jest.MatcherUtils, 9 | props: IApiProps, 10 | expected: IExpectedResponse, 11 | ) { 12 | verifyProps({ ...props }, expectedProps); 13 | 14 | const { url, method, params, data, headers } = props; 15 | 16 | try { 17 | const printMethod = this.utils.printExpected(method); 18 | const printUrl = this.utils.printExpected(url); 19 | 20 | const notHint = this.utils.matcherHint('.not.toReturnResponse') + EOL + EOL; 21 | const hint = this.utils.matcherHint('.toReturnResponse') + EOL + EOL; 22 | 23 | const received = await getResponse(url, method, params, data, headers); 24 | 25 | const pass = this.equals(expected, received); 26 | 27 | const printReceived = this.utils.printReceived(received); 28 | const printExpected = this.utils.printExpected(expected); 29 | 30 | if (pass) { 31 | return { 32 | message: () => 33 | `${notHint}Expected response ${printReceived} not to equal ${printExpected}.${EOL}` + 34 | `Request data: ${printMethod}: ${printUrl}`, 35 | pass: true, 36 | }; 37 | } else { 38 | const diffString = diff(expected, received, { 39 | expand: true, 40 | }); 41 | return { 42 | message: () => 43 | `${hint}Expected response ${printReceived} to equal ${printExpected}${EOL}` + 44 | `Difference:${diffString ? `${EOL}${EOL}${diffString}` : ''}${EOL}` + 45 | `Request data: ${printMethod}: ${printUrl}`, 46 | pass: false, 47 | }; 48 | } 49 | } catch (error) { 50 | const e = error as Error; 51 | // unknown error 52 | console.error(`Unknown error while getting response: ${e.message}`); 53 | throw e; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/jest/cloudwatch.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import * as originalUtils from 'jest-matcher-utils'; 3 | import { EOL } from 'os'; 4 | import * as common from '../common'; 5 | import { toHaveLog } from './cloudwatch'; 6 | 7 | jest.mock('../utils/cloudwatch'); 8 | jest.spyOn(console, 'error'); 9 | 10 | jest.spyOn(Date, 'parse').mockImplementation(() => 12 * 60 * 60 * 1000); 11 | jest.spyOn(common, 'verifyProps'); 12 | jest.spyOn(common, 'epochDateMinusHours'); 13 | 14 | describe('cloudwatch matchers', () => { 15 | describe('toHaveLog', () => { 16 | const matcherUtils = { 17 | equals: jest.fn(), 18 | expand: true, 19 | isNot: false, 20 | utils: { 21 | ...originalUtils, 22 | diff: jest.fn() as unknown, 23 | getType: jest.fn(), 24 | matcherHint: jest.fn((i) => i), 25 | printExpected: jest.fn((i) => i), 26 | printReceived: jest.fn((i) => i), 27 | }, 28 | } as unknown as jest.MatcherUtils & { equals: jest.Mock }; 29 | const region = 'region'; 30 | const functionName = 'functionName'; 31 | const startTime = 12 * 60 * 60 * 1000; 32 | const props = { region, function: functionName, startTime }; 33 | const pattern = 'pattern'; 34 | 35 | beforeEach(() => { 36 | jest.clearAllMocks(); 37 | 38 | const { getLogGroupName } = require('../utils/cloudwatch'); 39 | getLogGroupName.mockImplementation( 40 | (functionName: string) => `/aws/lambda/${functionName}`, 41 | ); 42 | }); 43 | 44 | test('should throw error on filterLogEvents error', async () => { 45 | const { verifyProps } = require('../common'); 46 | const { filterLogEvents } = require('../utils/cloudwatch'); 47 | 48 | const error = new Error('Unknown error'); 49 | filterLogEvents.mockReturnValue(Promise.reject(error)); 50 | 51 | expect.assertions(7); 52 | await expect(toHaveLog.bind(matcherUtils)(props, pattern)).rejects.toBe( 53 | error, 54 | ); 55 | expect(filterLogEvents).toHaveBeenCalledTimes(1); 56 | expect(filterLogEvents).toHaveBeenCalledWith( 57 | props.region, 58 | `/aws/lambda/${props.function}`, 59 | startTime, 60 | pattern, 61 | ); 62 | expect(console.error).toHaveBeenCalledTimes(1); 63 | expect(console.error).toHaveBeenCalledWith( 64 | `Unknown error while matching log: ${error.message}`, 65 | ); 66 | expect(verifyProps).toHaveBeenCalledTimes(1); 67 | expect(verifyProps).toHaveBeenCalledWith({ ...props, pattern }, [ 68 | 'region', 69 | 'pattern', 70 | ]); 71 | }); 72 | 73 | test('startTime should be defaulted when not passed in', async () => { 74 | const { epochDateMinusHours, verifyProps } = require('../common'); 75 | const { filterLogEvents } = require('../utils/cloudwatch'); 76 | 77 | const events: string[] = []; 78 | filterLogEvents.mockReturnValue(Promise.resolve({ events })); 79 | 80 | epochDateMinusHours.mockReturnValue(11 * 60 * 60 * 1000); 81 | 82 | const propsNoTime = { region, function: functionName }; 83 | await toHaveLog.bind(matcherUtils)(propsNoTime, pattern); 84 | 85 | expect(filterLogEvents).toHaveBeenCalledTimes(1); 86 | expect(filterLogEvents).toHaveBeenCalledWith( 87 | propsNoTime.region, 88 | `/aws/lambda/${props.function}`, 89 | 11 * 60 * 60 * 1000, 90 | pattern, 91 | ); 92 | expect(verifyProps).toHaveBeenCalledTimes(1); 93 | expect(verifyProps).toHaveBeenCalledWith({ ...propsNoTime, pattern }, [ 94 | 'region', 95 | 'pattern', 96 | ]); 97 | }); 98 | 99 | test('should pass custom log group name to filterLogEvents', async () => { 100 | const { filterLogEvents } = require('../utils/cloudwatch'); 101 | 102 | filterLogEvents.mockReturnValue(Promise.resolve({ events: ['event'] })); 103 | 104 | await toHaveLog.bind(matcherUtils)( 105 | { ...props, logGroupName: 'customLogGroup' }, 106 | pattern, 107 | ); 108 | 109 | expect(filterLogEvents).toHaveBeenCalledTimes(1); 110 | expect(filterLogEvents).toHaveBeenCalledWith( 111 | props.region, 112 | 'customLogGroup', 113 | props.startTime, 114 | pattern, 115 | ); 116 | }); 117 | 118 | test('should not pass when no events found', async () => { 119 | const { filterLogEvents } = require('../utils/cloudwatch'); 120 | 121 | const events: string[] = []; 122 | filterLogEvents.mockReturnValue(Promise.resolve({ events })); 123 | 124 | const { message, pass } = await toHaveLog.bind(matcherUtils)( 125 | props, 126 | pattern, 127 | ); 128 | 129 | expect(pass).toBeFalsy(); 130 | expect(message).toEqual(expect.any(Function)); 131 | expect(message()).toEqual( 132 | `.toHaveLog${EOL}${EOL}Expected ${functionName} at region ${region} to have log matching pattern ${pattern}${EOL}`, 133 | ); 134 | }); 135 | 136 | test('should pass when events found', async () => { 137 | const { filterLogEvents } = require('../utils/cloudwatch'); 138 | 139 | const events = ['someFakeEvent']; 140 | 141 | filterLogEvents.mockReturnValue(Promise.resolve({ events })); 142 | 143 | const { message, pass } = await toHaveLog.bind(matcherUtils)( 144 | props, 145 | pattern, 146 | ); 147 | 148 | expect(pass).toBeTruthy(); 149 | expect(message).toEqual(expect.any(Function)); 150 | expect(message()).toEqual( 151 | `.not.toHaveLog${EOL}${EOL}Expected ${functionName} at region ${region} not to have log matching pattern ${pattern}${EOL}`, 152 | ); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/jest/cloudwatch.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'os'; 2 | import { expectedProps, ICloudwatchProps } from '../common/cloudwatch'; 3 | import { epochDateMinusHours, verifyProps } from '../common/index'; 4 | import { filterLogEvents, getLogGroupName } from '../utils/cloudwatch'; 5 | 6 | export const toHaveLog = async function ( 7 | this: jest.MatcherUtils, 8 | props: ICloudwatchProps, 9 | pattern: string, 10 | ) { 11 | verifyProps({ ...props, pattern }, expectedProps); 12 | const { 13 | region, 14 | function: functionName, 15 | startTime = epochDateMinusHours(1), 16 | logGroupName, 17 | } = props; 18 | 19 | try { 20 | const messageSubject = this.utils.printExpected( 21 | logGroupName || functionName, 22 | ); 23 | const printRegion = this.utils.printExpected(region); 24 | const printPattern = this.utils.printExpected(pattern) + EOL; 25 | 26 | const notHint = this.utils.matcherHint('.not.toHaveLog') + EOL + EOL; 27 | const hint = this.utils.matcherHint('.toHaveLog') + EOL + EOL; 28 | 29 | const { events } = await filterLogEvents( 30 | region, 31 | logGroupName || getLogGroupName(functionName || ''), 32 | startTime, 33 | pattern, 34 | ); 35 | const found = events.length > 0; 36 | if (found) { 37 | // matching log found 38 | return { 39 | message: () => 40 | `${notHint}Expected ${messageSubject} at region ${printRegion} not to have log matching pattern ${printPattern}`, 41 | pass: true, 42 | }; 43 | } else { 44 | // matching log not found 45 | return { 46 | message: () => 47 | `${hint}Expected ${messageSubject} at region ${printRegion} to have log matching pattern ${printPattern}`, 48 | pass: false, 49 | }; 50 | } 51 | } catch (error) { 52 | const e = error as Error; 53 | // unknown error 54 | console.error(`Unknown error while matching log: ${e.message}`); 55 | throw e; 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/jest/dynamoDb.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import * as originalUtils from 'jest-matcher-utils'; 3 | import { EOL } from 'os'; 4 | import { toHaveItem } from './dynamoDb'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/dynamoDb'); 8 | jest.spyOn(console, 'error'); 9 | jest.mock('jest-diff'); 10 | 11 | describe('dynamoDb matchers', () => { 12 | describe('toHaveItem', () => { 13 | const matcherUtils = { 14 | equals: jest.fn(), 15 | expand: true, 16 | isNot: false, 17 | utils: { 18 | ...originalUtils, 19 | diff: jest.fn() as unknown, 20 | getType: jest.fn(), 21 | matcherHint: jest.fn((i) => i), 22 | printExpected: jest.fn((i) => i), 23 | printReceived: jest.fn((i) => i), 24 | }, 25 | } as unknown as jest.MatcherUtils & { equals: jest.Mock }; 26 | const region = 'region'; 27 | const table = 'table'; 28 | const props = { region, table }; 29 | const key = { id: { S: 'id' } }; 30 | 31 | beforeEach(() => { 32 | jest.clearAllMocks(); 33 | }); 34 | 35 | test('should throw error on getItem error', async () => { 36 | const { verifyProps } = require('../common'); 37 | const { getItem } = require('../utils/dynamoDb'); 38 | 39 | const error = new Error('Unknown error'); 40 | getItem.mockReturnValue(Promise.reject(error)); 41 | 42 | expect.assertions(7); 43 | await expect(toHaveItem.bind(matcherUtils)(props, key)).rejects.toBe( 44 | error, 45 | ); 46 | expect(getItem).toHaveBeenCalledTimes(1); 47 | expect(getItem).toHaveBeenCalledWith(props.region, props.table, key); 48 | expect(console.error).toHaveBeenCalledTimes(1); 49 | expect(console.error).toHaveBeenCalledWith( 50 | `Unknown error while looking for item: ${error.message}`, 51 | ); 52 | expect(verifyProps).toHaveBeenCalledTimes(1); 53 | expect(verifyProps).toHaveBeenCalledWith({ ...props, key }, [ 54 | 'region', 55 | 'table', 56 | 'key', 57 | ]); 58 | }); 59 | 60 | test('should not pass on getItem not found', async () => { 61 | const { getItem } = require('../utils/dynamoDb'); 62 | 63 | getItem.mockReturnValue(Promise.resolve(undefined)); 64 | 65 | const { message, pass } = await toHaveItem.bind(matcherUtils)(props, key); 66 | 67 | expect(pass).toBeFalsy(); 68 | expect(message).toEqual(expect.any(Function)); 69 | expect(message()).toEqual( 70 | `.toHaveItem${EOL}${EOL}Expected ${table} at region ${region} to have item with key ${key}${EOL}`, 71 | ); 72 | }); 73 | 74 | test('should pass on getItem found', async () => { 75 | const { getItem } = require('../utils/dynamoDb'); 76 | 77 | getItem.mockReturnValue(Promise.resolve('someItem')); 78 | 79 | const { message, pass } = await toHaveItem.bind(matcherUtils)(props, key); 80 | 81 | expect(pass).toBeTruthy(); 82 | expect(message).toEqual(expect.any(Function)); 83 | expect(message()).toEqual( 84 | `.not.toHaveItem${EOL}${EOL}Expected ${table} at region ${region} not to have item with key ${key}${EOL}`, 85 | ); 86 | }); 87 | 88 | test('should not pass on getItem item not matching', async () => { 89 | const { diff } = require('jest-diff'); 90 | const diffString = 'diffString'; 91 | diff.mockReturnValue(diffString); 92 | 93 | matcherUtils.equals.mockReturnValue(false); 94 | 95 | const { getItem } = require('../utils/dynamoDb'); 96 | 97 | const received = { id: { S: 'someItem' } }; 98 | getItem.mockReturnValue(Promise.resolve(received)); 99 | 100 | const expected = { id: { S: 'otherItem' } }; 101 | const { message, pass } = await toHaveItem.bind(matcherUtils)( 102 | props, 103 | key, 104 | expected, 105 | ); 106 | 107 | expect(pass).toBeFalsy(); 108 | expect(message).toEqual(expect.any(Function)); 109 | expect(message()).toEqual( 110 | `.toHaveItem${EOL}${EOL}Expected item ${received} to equal ${expected}${EOL}Difference:${EOL}${EOL}${diffString}`, 111 | ); 112 | expect(matcherUtils.equals).toHaveBeenCalledTimes(1); 113 | expect(matcherUtils.equals).toHaveBeenCalledWith(expected, received); 114 | expect(diff).toHaveBeenCalledTimes(1); 115 | expect(diff).toHaveBeenCalledWith(expected, received, { 116 | expand: true, 117 | }); 118 | }); 119 | 120 | test('should not pass on getItem item not matching empty diffString', async () => { 121 | const { diff } = require('jest-diff'); 122 | const diffString = ''; 123 | diff.mockReturnValue(diffString); 124 | 125 | matcherUtils.equals.mockReturnValue(false); 126 | 127 | const { getItem } = require('../utils/dynamoDb'); 128 | 129 | const received = { 130 | id: { S: 'someId' }, 131 | text: { S: 'someText' }, 132 | timestamp: { N: new Date('1948/1/1').getTime().toString() }, 133 | }; 134 | getItem.mockReturnValue(Promise.resolve(received)); 135 | 136 | const expected = { 137 | id: { S: 'someId' }, 138 | text: { S: 'someText' }, 139 | timestamp: { N: new Date('1949/1/1').getTime().toString() }, 140 | }; 141 | const { message, pass } = await toHaveItem.bind(matcherUtils)( 142 | props, 143 | key, 144 | expected, 145 | ); 146 | 147 | expect(pass).toBeFalsy(); 148 | expect(message()).toEqual( 149 | `.toHaveItem${EOL}${EOL}Expected item ${received} to equal ${expected}${EOL}Difference:`, 150 | ); 151 | }); 152 | 153 | test('should pass on getItem item matching', async () => { 154 | const { diff } = require('jest-diff'); 155 | 156 | matcherUtils.equals.mockReturnValue(true); 157 | 158 | const { getItem } = require('../utils/dynamoDb'); 159 | 160 | const timestamp = { N: new Date().getTime().toString() }; 161 | const received = { 162 | id: { S: 'someId' }, 163 | text: { S: 'someText' }, 164 | timestamp, 165 | }; 166 | getItem.mockReturnValue(Promise.resolve(received)); 167 | 168 | const expected = { 169 | id: { S: 'someId' }, 170 | text: { S: 'someText' }, 171 | timestamp, 172 | }; 173 | const { message, pass } = await toHaveItem.bind(matcherUtils)( 174 | props, 175 | key, 176 | expected, 177 | ); 178 | 179 | expect(pass).toBeTruthy(); 180 | expect(message).toEqual(expect.any(Function)); 181 | expect(message()).toEqual( 182 | `.not.toHaveItem${EOL}${EOL}Expected item ${received} not to equal ${expected}`, 183 | ); 184 | expect(matcherUtils.equals).toHaveBeenCalledTimes(1); 185 | expect(matcherUtils.equals).toHaveBeenCalledWith(expected, received); 186 | expect(diff).toHaveBeenCalledTimes(0); 187 | }); 188 | 189 | test('should pass on getItem item matching, non strict mode', async () => { 190 | matcherUtils.equals.mockReturnValue(true); 191 | 192 | const { getItem } = require('../utils/dynamoDb'); 193 | 194 | const id = { S: 'someId' }; 195 | const text = { S: 'text' }; 196 | const received = { 197 | id, 198 | text, 199 | timestamp: { N: new Date().getTime().toString() }, 200 | }; 201 | getItem.mockReturnValue(Promise.resolve(received)); 202 | 203 | const expected = { 204 | id, 205 | text, 206 | }; 207 | const { message, pass } = await toHaveItem.bind(matcherUtils)( 208 | props, 209 | key, 210 | expected, 211 | false, 212 | ); 213 | 214 | expect(pass).toBeTruthy(); 215 | expect(message).toEqual(expect.any(Function)); 216 | expect(message()).toEqual( 217 | `.not.toHaveItem${EOL}${EOL}Expected item ${received} not to equal ${expected}`, 218 | ); 219 | expect(matcherUtils.equals).toHaveBeenCalledTimes(1); 220 | expect(matcherUtils.equals).toHaveBeenCalledWith(expected, { id, text }); 221 | }); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /src/jest/dynamoDb.ts: -------------------------------------------------------------------------------- 1 | import { diff } from 'jest-diff'; 2 | import { EOL } from 'os'; 3 | import { verifyProps } from '../common'; 4 | import { 5 | expectedProps, 6 | IDynamoDbProps, 7 | removeKeysFromItemForNonStrictComparison, 8 | } from '../common/dynamoDb'; 9 | import { getItem } from '../utils/dynamoDb'; 10 | 11 | export const toHaveItem = async function ( 12 | this: jest.MatcherUtils, 13 | props: IDynamoDbProps, 14 | key: AWS.DynamoDB.DocumentClient.Key, 15 | expected?: AWS.DynamoDB.DocumentClient.AttributeMap, 16 | strict = true, 17 | ) { 18 | verifyProps({ ...props, key }, expectedProps); 19 | 20 | const { region, table } = props; 21 | 22 | try { 23 | const printTable = this.utils.printExpected(table); 24 | const printRegion = this.utils.printExpected(region); 25 | const printKey = this.utils.printExpected(key) + EOL; 26 | 27 | const notHint = this.utils.matcherHint('.not.toHaveItem') + EOL + EOL; 28 | const hint = this.utils.matcherHint('.toHaveItem') + EOL + EOL; 29 | 30 | let received = await getItem(region, table, key); 31 | // check if item was found 32 | if (received) { 33 | // no expected item to compare with 34 | if (!expected) { 35 | return { 36 | message: () => 37 | `${notHint}Expected ${printTable} at region ${printRegion} not to have item with key ${printKey}`, 38 | pass: true, 39 | }; 40 | } else { 41 | if (!strict) { 42 | received = removeKeysFromItemForNonStrictComparison( 43 | received, 44 | expected, 45 | ); 46 | } 47 | // we check equality as well 48 | const pass = this.equals(expected, received); 49 | 50 | const printReceived = this.utils.printReceived(received); 51 | const printExpected = this.utils.printExpected(expected); 52 | 53 | if (pass) { 54 | return { 55 | message: () => 56 | `${notHint}Expected item ${printReceived} not to equal ${printExpected}`, 57 | pass: true, 58 | }; 59 | } else { 60 | const diffString = diff(expected, received, { 61 | expand: true, 62 | }); 63 | return { 64 | message: () => 65 | `${hint}Expected item ${printReceived} to equal ${printExpected}${EOL}` + 66 | `Difference:${diffString ? `${EOL}${EOL}${diffString}` : ''}`, 67 | pass: false, 68 | }; 69 | } 70 | } 71 | } else { 72 | // no item was found 73 | return { 74 | message: () => 75 | `${hint}Expected ${printTable} at region ${printRegion} to have item with key ${printKey}`, 76 | pass: false, 77 | }; 78 | } 79 | } catch (error) { 80 | const e = error as Error; 81 | // unknown error 82 | console.error(`Unknown error while looking for item: ${e.message}`); 83 | throw e; 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /src/jest/index.ts: -------------------------------------------------------------------------------- 1 | import { IExpectedResponse } from '../common/api'; 2 | import { IRecordMatcher } from '../utils/kinesis'; 3 | import { IMessageMatcher } from '../utils/sqs'; 4 | import { toReturnResponse } from './api'; 5 | import { toHaveLog } from './cloudwatch'; 6 | import { toHaveItem } from './dynamoDb'; 7 | import { toHaveRecord } from './kinesis'; 8 | import { toHaveObject } from './s3'; 9 | import { toHaveMessage } from './sqs'; 10 | import { toBeAtState, toHaveState } from './stepFunctions'; 11 | import { wrapWithRetries } from './utils'; 12 | 13 | declare global { 14 | namespace jest { 15 | interface Matchers { 16 | toBeAtState: (state: string) => R; 17 | toHaveItem: ( 18 | key: AWS.DynamoDB.DocumentClient.Key, 19 | expectedItem?: AWS.DynamoDB.DocumentClient.AttributeMap, 20 | strict?: boolean, 21 | ) => R; 22 | toHaveLog: (pattern: string) => R; 23 | toHaveObject: (key: string, expectedItem?: Buffer) => R; 24 | toHaveRecord: (matcher: IRecordMatcher) => R; 25 | toHaveMessage: (matcher: IMessageMatcher) => R; 26 | toHaveState: (state: string) => R; 27 | toReturnResponse: (expected: IExpectedResponse) => R; 28 | } 29 | } 30 | } 31 | 32 | expect.extend({ 33 | toBeAtState: wrapWithRetries(toBeAtState) as typeof toBeAtState, 34 | toHaveItem: wrapWithRetries(toHaveItem) as typeof toHaveItem, 35 | toHaveLog: wrapWithRetries(toHaveLog) as typeof toHaveLog, 36 | toHaveMessage: wrapWithRetries(toHaveMessage) as typeof toHaveMessage, 37 | toHaveObject: wrapWithRetries(toHaveObject) as typeof toHaveObject, 38 | toHaveRecord, // has built in timeout mechanism due to how kinesis consumer works 39 | toHaveState: wrapWithRetries(toHaveState) as typeof toHaveState, 40 | toReturnResponse, // synchronous so no need to retry 41 | }); 42 | 43 | jest.setTimeout(60000); 44 | -------------------------------------------------------------------------------- /src/jest/kinesis.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import * as originalUtils from 'jest-matcher-utils'; 3 | import { EOL } from 'os'; 4 | import { toHaveRecord } from './kinesis'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/kinesis'); 8 | jest.spyOn(console, 'error'); 9 | 10 | describe('kinesis matchers', () => { 11 | describe('toHaveRecord', () => { 12 | const matcherUtils = { 13 | equals: jest.fn(), 14 | expand: true, 15 | isNot: false, 16 | utils: { 17 | ...originalUtils, 18 | diff: jest.fn() as unknown, 19 | getType: jest.fn(), 20 | matcherHint: jest.fn((i) => i), 21 | printExpected: jest.fn((i) => i), 22 | printReceived: jest.fn((i) => i), 23 | }, 24 | } as unknown as jest.MatcherUtils & { equals: jest.Mock }; 25 | const region = 'region'; 26 | const stream = 'stream'; 27 | const props = { region, stream }; 28 | const matcher = jest.fn(); 29 | 30 | beforeEach(() => { 31 | jest.clearAllMocks(); 32 | }); 33 | 34 | test('should throw error on existsInStream error', async () => { 35 | const { verifyProps } = require('../common'); 36 | const { existsInStream } = require('../utils/kinesis'); 37 | 38 | const error = new Error('Unknown error'); 39 | existsInStream.mockReturnValue(Promise.reject(error)); 40 | 41 | expect.assertions(7); 42 | await expect( 43 | toHaveRecord.bind(matcherUtils)(props, matcher), 44 | ).rejects.toBe(error); 45 | expect(existsInStream).toHaveBeenCalledTimes(1); 46 | expect(existsInStream).toHaveBeenCalledWith( 47 | props.region, 48 | props.stream, 49 | matcher, 50 | 10000, 51 | 500, 52 | ); 53 | expect(console.error).toHaveBeenCalledTimes(1); 54 | expect(console.error).toHaveBeenCalledWith( 55 | `Unknown error while looking for record: ${error.message}`, 56 | ); 57 | expect(verifyProps).toHaveBeenCalledTimes(1); 58 | expect(verifyProps).toHaveBeenCalledWith({ ...props, matcher }, [ 59 | 'region', 60 | 'stream', 61 | 'matcher', 62 | ]); 63 | }); 64 | 65 | test('should not pass on existsInStream returns false', async () => { 66 | const { existsInStream } = require('../utils/kinesis'); 67 | 68 | existsInStream.mockReturnValue(Promise.resolve(false)); 69 | 70 | const { message, pass } = await toHaveRecord.bind(matcherUtils)( 71 | props, 72 | matcher, 73 | ); 74 | 75 | expect.assertions(3); 76 | 77 | expect(pass).toBeFalsy(); 78 | expect(message).toEqual(expect.any(Function)); 79 | expect(message()).toEqual( 80 | `.toHaveRecord${EOL}${EOL}Expected ${stream} at region ${region} to have record`, 81 | ); 82 | }); 83 | 84 | test('should pass on existsInStream returns true', async () => { 85 | const { existsInStream } = require('../utils/kinesis'); 86 | 87 | existsInStream.mockReturnValue(Promise.resolve(true)); 88 | 89 | const { message, pass } = await toHaveRecord.bind(matcherUtils)( 90 | props, 91 | matcher, 92 | ); 93 | 94 | expect.assertions(3); 95 | 96 | expect(pass).toBeTruthy(); 97 | expect(message).toEqual(expect.any(Function)); 98 | expect(message()).toEqual( 99 | `.not.toHaveRecord${EOL}${EOL}Expected ${stream} at region ${region} not to have record`, 100 | ); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/jest/kinesis.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'os'; 2 | import { verifyProps } from '../common'; 3 | import { expectedProps, IKinesisProps } from '../common/kinesis'; 4 | import { existsInStream, IRecordMatcher } from '../utils/kinesis'; 5 | 6 | export const toHaveRecord = async function ( 7 | this: jest.MatcherUtils, 8 | props: IKinesisProps, 9 | matcher: IRecordMatcher, 10 | ) { 11 | verifyProps({ ...props, matcher }, expectedProps); 12 | 13 | const { region, stream, timeout = 10 * 1000, pollEvery = 500 } = props; 14 | 15 | try { 16 | const printStream = this.utils.printExpected(stream); 17 | const printRegion = this.utils.printExpected(region); 18 | 19 | const notHint = this.utils.matcherHint('.not.toHaveRecord') + EOL + EOL; 20 | const hint = this.utils.matcherHint('.toHaveRecord') + EOL + EOL; 21 | 22 | const found = await existsInStream( 23 | region, 24 | stream, 25 | matcher, 26 | timeout, 27 | pollEvery, 28 | ); 29 | // check if record was found 30 | if (found) { 31 | return { 32 | message: () => 33 | `${notHint}Expected ${printStream} at region ${printRegion} not to have record`, 34 | pass: true, 35 | }; 36 | } else { 37 | // no record was found 38 | return { 39 | message: () => 40 | `${hint}Expected ${printStream} at region ${printRegion} to have record`, 41 | pass: false, 42 | }; 43 | } 44 | } catch (error) { 45 | const e = error as Error; 46 | // unknown error 47 | console.error(`Unknown error while looking for record: ${e.message}`); 48 | throw e; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/jest/s3.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import * as originalUtils from 'jest-matcher-utils'; 3 | import { EOL } from 'os'; 4 | import { toHaveObject } from './s3'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/s3'); 8 | jest.spyOn(console, 'error'); 9 | jest.mock('jest-diff'); 10 | 11 | describe('s3 matchers', () => { 12 | describe('toHaveObject', () => { 13 | const matcherUtils = { 14 | equals: jest.fn(), 15 | expand: true, 16 | isNot: false, 17 | utils: { 18 | ...originalUtils, 19 | diff: jest.fn() as unknown, 20 | getType: jest.fn(), 21 | matcherHint: jest.fn((i) => i), 22 | printExpected: jest.fn((i) => i), 23 | printReceived: jest.fn((i) => i), 24 | }, 25 | } as unknown as jest.MatcherUtils & { equals: jest.Mock }; 26 | const region = 'region'; 27 | const bucket = 'bucket'; 28 | const props = { region, bucket }; 29 | const key = 'key'; 30 | 31 | beforeEach(() => { 32 | jest.clearAllMocks(); 33 | }); 34 | 35 | test('should throw error on getObject error', async () => { 36 | const { verifyProps } = require('../common'); 37 | const { getObject } = require('../utils/s3'); 38 | 39 | const error = new Error('Unknown error'); 40 | getObject.mockReturnValue(Promise.reject(error)); 41 | 42 | expect.assertions(7); 43 | await expect(toHaveObject.bind(matcherUtils)(props, key)).rejects.toBe( 44 | error, 45 | ); 46 | expect(getObject).toHaveBeenCalledTimes(1); 47 | expect(getObject).toHaveBeenCalledWith(props.region, props.bucket, key); 48 | expect(console.error).toHaveBeenCalledTimes(1); 49 | expect(console.error).toHaveBeenCalledWith( 50 | `Unknown error while looking for object: ${error.message}`, 51 | ); 52 | expect(verifyProps).toHaveBeenCalledTimes(1); 53 | expect(verifyProps).toHaveBeenCalledWith({ ...props, key }, [ 54 | 'region', 55 | 'bucket', 56 | 'key', 57 | ]); 58 | }); 59 | 60 | test('should not pass on getObject not found', async () => { 61 | const { getObject } = require('../utils/s3'); 62 | 63 | getObject.mockReturnValue(Promise.resolve({ body: null, found: false })); 64 | 65 | const { message, pass } = await toHaveObject.bind(matcherUtils)( 66 | props, 67 | key, 68 | ); 69 | 70 | expect(pass).toBeFalsy(); 71 | expect(message).toEqual(expect.any(Function)); 72 | expect(message()).toEqual( 73 | `.toHaveObject${EOL}${EOL}Expected ${bucket} at region ${region} to have object with key ${key}${EOL}`, 74 | ); 75 | }); 76 | 77 | test('should pass on getObject found', async () => { 78 | const { getObject } = require('../utils/s3'); 79 | 80 | getObject.mockReturnValue( 81 | Promise.resolve({ body: Buffer.from('some data'), found: true }), 82 | ); 83 | 84 | const { message, pass } = await toHaveObject.bind(matcherUtils)( 85 | props, 86 | key, 87 | ); 88 | 89 | expect(pass).toBeTruthy(); 90 | expect(message).toEqual(expect.any(Function)); 91 | expect(message()).toEqual( 92 | `.not.toHaveObject${EOL}${EOL}Expected ${bucket} at region ${region} not to have object with key ${key}${EOL}`, 93 | ); 94 | }); 95 | 96 | test('should not pass on getObject buffer not matching', async () => { 97 | const { diff } = require('jest-diff'); 98 | const diffString = 'diffString'; 99 | diff.mockReturnValue(diffString); 100 | 101 | matcherUtils.equals.mockReturnValue(false); 102 | 103 | const { getObject } = require('../utils/s3'); 104 | 105 | const received = Buffer.from('actual'); 106 | getObject.mockReturnValue( 107 | Promise.resolve({ body: received, found: true }), 108 | ); 109 | 110 | const expected = Buffer.from('expected'); 111 | const { message, pass } = await toHaveObject.bind(matcherUtils)( 112 | props, 113 | key, 114 | expected, 115 | ); 116 | 117 | expect(pass).toBeFalsy(); 118 | expect(message).toEqual(expect.any(Function)); 119 | expect(message()).toEqual( 120 | `.toHaveObject${EOL}${EOL}Expected object ${received} to equal ${expected}${EOL}Difference:${EOL}${EOL}${diffString}`, 121 | ); 122 | expect(matcherUtils.equals).toHaveBeenCalledTimes(1); 123 | expect(matcherUtils.equals).toHaveBeenCalledWith(expected, received); 124 | expect(diff).toHaveBeenCalledTimes(1); 125 | expect(diff).toHaveBeenCalledWith(expected, received, { 126 | expand: true, 127 | }); 128 | }); 129 | 130 | test('should not pass on getObject buffer not matching empty diffString', async () => { 131 | const { diff } = require('jest-diff'); 132 | const diffString = ''; 133 | diff.mockReturnValue(diffString); 134 | 135 | matcherUtils.equals.mockReturnValue(false); 136 | 137 | const { getObject } = require('../utils/s3'); 138 | 139 | const received = Buffer.from('actual'); 140 | getObject.mockReturnValue( 141 | Promise.resolve({ body: received, found: true }), 142 | ); 143 | 144 | const expected = Buffer.from('expected'); 145 | const { message, pass } = await toHaveObject.bind(matcherUtils)( 146 | props, 147 | key, 148 | expected, 149 | ); 150 | 151 | expect(pass).toBeFalsy(); 152 | expect(message()).toEqual( 153 | `.toHaveObject${EOL}${EOL}Expected object ${received} to equal ${expected}${EOL}Difference:`, 154 | ); 155 | }); 156 | 157 | test('should pass on getObject buffer matching', async () => { 158 | const { diff } = require('jest-diff'); 159 | 160 | matcherUtils.equals.mockReturnValue(true); 161 | 162 | const { getObject } = require('../utils/s3'); 163 | 164 | const received = Buffer.from('actual'); 165 | getObject.mockReturnValue( 166 | Promise.resolve({ body: received, found: true }), 167 | ); 168 | 169 | const expected = Buffer.from('expected'); 170 | const { message, pass } = await toHaveObject.bind(matcherUtils)( 171 | props, 172 | key, 173 | expected, 174 | ); 175 | 176 | expect(pass).toBeTruthy(); 177 | expect(message).toEqual(expect.any(Function)); 178 | expect(message()).toEqual( 179 | `.not.toHaveObject${EOL}${EOL}Expected object ${received} not to equal ${expected}`, 180 | ); 181 | expect(matcherUtils.equals).toHaveBeenCalledTimes(1); 182 | expect(matcherUtils.equals).toHaveBeenCalledWith(expected, received); 183 | expect(diff).toHaveBeenCalledTimes(0); 184 | }); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /src/jest/s3.ts: -------------------------------------------------------------------------------- 1 | import { diff } from 'jest-diff'; 2 | import { EOL } from 'os'; 3 | import { verifyProps } from '../common'; 4 | import { expectedProps, IS3Props } from '../common/s3'; 5 | import { getObject } from '../utils/s3'; 6 | 7 | export const toHaveObject = async function ( 8 | this: jest.MatcherUtils, 9 | props: IS3Props, 10 | key: string, 11 | expected?: Buffer, 12 | ) { 13 | verifyProps({ ...props, key }, expectedProps); 14 | 15 | const { region, bucket } = props; 16 | 17 | try { 18 | const printBucket = this.utils.printExpected(bucket); 19 | const printRegion = this.utils.printExpected(region); 20 | const printKey = this.utils.printExpected(key) + EOL; 21 | 22 | const notHint = this.utils.matcherHint('.not.toHaveObject') + EOL + EOL; 23 | const hint = this.utils.matcherHint('.toHaveObject') + EOL + EOL; 24 | 25 | const { body: received, found } = await getObject(region, bucket, key); 26 | // check if object was found 27 | if (found) { 28 | // no expected buffer to compare with 29 | if (!expected) { 30 | return { 31 | message: () => 32 | `${notHint}Expected ${printBucket} at region ${printRegion} not to have object with key ${printKey}`, 33 | pass: true, 34 | }; 35 | } else { 36 | // we check equality as well 37 | const pass = this.equals(expected, received); 38 | 39 | const printReceived = this.utils.printReceived(received); 40 | const printExpected = this.utils.printExpected(expected); 41 | 42 | if (pass) { 43 | return { 44 | message: () => 45 | `${notHint}Expected object ${printReceived} not to equal ${printExpected}`, 46 | pass: true, 47 | }; 48 | } else { 49 | const diffString = diff(expected, received, { 50 | expand: true, 51 | }); 52 | return { 53 | message: () => 54 | `${hint}Expected object ${printReceived} to equal ${printExpected}${EOL}` + 55 | `Difference:${diffString ? `${EOL}${EOL}${diffString}` : ''}`, 56 | pass: false, 57 | }; 58 | } 59 | } 60 | } else { 61 | // no item was found 62 | return { 63 | message: () => 64 | `${hint}Expected ${printBucket} at region ${printRegion} to have object with key ${printKey}`, 65 | pass: false, 66 | }; 67 | } 68 | } catch (error) { 69 | const e = error as Error; 70 | // unknown error 71 | console.error(`Unknown error while looking for object: ${e.message}`); 72 | throw e; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/jest/sqs.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import * as originalUtils from 'jest-matcher-utils'; 3 | import { EOL } from 'os'; 4 | import { toHaveMessage } from './sqs'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/sqs'); 8 | jest.spyOn(console, 'error'); 9 | 10 | describe('sqs matchers', () => { 11 | describe('toHaveRecord', () => { 12 | const matcherUtils = { 13 | equals: jest.fn(), 14 | expand: true, 15 | isNot: false, 16 | utils: { 17 | ...originalUtils, 18 | diff: jest.fn() as unknown, 19 | getType: jest.fn(), 20 | matcherHint: jest.fn((i) => i), 21 | printExpected: jest.fn((i) => i), 22 | printReceived: jest.fn((i) => i), 23 | }, 24 | } as unknown as jest.MatcherUtils & { equals: jest.Mock }; 25 | const region = 'region'; 26 | const queueUrl = 'queueUrl'; 27 | const props = { region, queueUrl }; 28 | const matcher = jest.fn(); 29 | 30 | beforeEach(() => { 31 | jest.clearAllMocks(); 32 | }); 33 | 34 | test('should throw error on existsInQueue error', async () => { 35 | const { verifyProps } = require('../common'); 36 | const { existsInQueue } = require('../utils/sqs'); 37 | 38 | const error = new Error('Unknown error'); 39 | existsInQueue.mockReturnValue(Promise.reject(error)); 40 | 41 | expect.assertions(7); 42 | await expect( 43 | toHaveMessage.bind(matcherUtils)(props, matcher), 44 | ).rejects.toBe(error); 45 | expect(existsInQueue).toHaveBeenCalledTimes(1); 46 | expect(existsInQueue).toHaveBeenCalledWith( 47 | props.region, 48 | props.queueUrl, 49 | matcher, 50 | ); 51 | expect(console.error).toHaveBeenCalledTimes(1); 52 | expect(console.error).toHaveBeenCalledWith( 53 | `Unknown error while looking for message: ${error.message}`, 54 | ); 55 | expect(verifyProps).toHaveBeenCalledTimes(1); 56 | expect(verifyProps).toHaveBeenCalledWith({ ...props, matcher }, [ 57 | 'region', 58 | 'queueUrl', 59 | 'matcher', 60 | ]); 61 | }); 62 | 63 | test('should not pass on existsInQueue returns false', async () => { 64 | const { existsInQueue } = require('../utils/sqs'); 65 | 66 | existsInQueue.mockReturnValue(Promise.resolve(false)); 67 | 68 | const { message, pass } = await toHaveMessage.bind(matcherUtils)( 69 | props, 70 | matcher, 71 | ); 72 | 73 | expect.assertions(3); 74 | 75 | expect(pass).toBeFalsy(); 76 | expect(message).toEqual(expect.any(Function)); 77 | expect(message()).toEqual( 78 | `.toHaveMessage${EOL}${EOL}Expected ${queueUrl} at region ${region} to have message`, 79 | ); 80 | }); 81 | 82 | test('should pass on existsInQueue returns true', async () => { 83 | const { existsInQueue } = require('../utils/sqs'); 84 | 85 | existsInQueue.mockReturnValue(Promise.resolve(true)); 86 | 87 | const { message, pass } = await toHaveMessage.bind(matcherUtils)( 88 | props, 89 | matcher, 90 | ); 91 | 92 | expect.assertions(3); 93 | 94 | expect(pass).toBeTruthy(); 95 | expect(message).toEqual(expect.any(Function)); 96 | expect(message()).toEqual( 97 | `.not.toHaveMessage${EOL}${EOL}Expected ${queueUrl} at region ${region} not to have message`, 98 | ); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/jest/sqs.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'os'; 2 | import { verifyProps } from '../common'; 3 | import { expectedProps, ISqsProps } from '../common/sqs'; 4 | import { existsInQueue, IMessageMatcher } from '../utils/sqs'; 5 | 6 | export const toHaveMessage = async function ( 7 | this: jest.MatcherUtils, 8 | props: ISqsProps, 9 | matcher: IMessageMatcher, 10 | ) { 11 | verifyProps({ ...props, matcher }, expectedProps); 12 | 13 | const { region, queueUrl } = props; 14 | 15 | try { 16 | const printQueueUrl = this.utils.printExpected(queueUrl); 17 | const printRegion = this.utils.printExpected(region); 18 | 19 | const notHint = this.utils.matcherHint('.not.toHaveMessage') + EOL + EOL; 20 | const hint = this.utils.matcherHint('.toHaveMessage') + EOL + EOL; 21 | 22 | const found = await existsInQueue(region, queueUrl, matcher); 23 | // check if record was found 24 | if (found) { 25 | return { 26 | message: () => 27 | `${notHint}Expected ${printQueueUrl} at region ${printRegion} not to have message`, 28 | pass: true, 29 | }; 30 | } else { 31 | // no record was found 32 | return { 33 | message: () => 34 | `${hint}Expected ${printQueueUrl} at region ${printRegion} to have message`, 35 | pass: false, 36 | }; 37 | } 38 | } catch (error) { 39 | const e = error as Error; 40 | // unknown error 41 | console.error(`Unknown error while looking for message: ${e.message}`); 42 | throw e; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/jest/stepFunctions.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import * as originalUtils from 'jest-matcher-utils'; 3 | import { EOL } from 'os'; 4 | import { toBeAtState, toHaveState } from './stepFunctions'; 5 | 6 | jest.mock('../common'); 7 | jest.mock('../utils/stepFunctions'); 8 | jest.spyOn(console, 'error'); 9 | jest.mock('jest-diff'); 10 | 11 | describe('stepFunctions matchers', () => { 12 | const matcherUtils = { 13 | equals: jest.fn(), 14 | expand: true, 15 | isNot: false, 16 | utils: { 17 | ...originalUtils, 18 | diff: jest.fn() as unknown, 19 | getType: jest.fn(), 20 | matcherHint: jest.fn((i) => i), 21 | printExpected: jest.fn((i) => i), 22 | printReceived: jest.fn((i) => i), 23 | }, 24 | } as unknown as jest.MatcherUtils & { equals: jest.Mock }; 25 | const region = 'region'; 26 | const stateMachineArn = 'stateMachineArn'; 27 | const props = { region, stateMachineArn }; 28 | const expectedState = 'expectedState'; 29 | 30 | describe('toBeAtState', () => { 31 | beforeEach(() => { 32 | jest.clearAllMocks(); 33 | }); 34 | 35 | test('should throw error on getCurrentState error', async () => { 36 | const { verifyProps } = require('../common'); 37 | const { getCurrentState } = require('../utils/stepFunctions'); 38 | 39 | const error = new Error('Unknown error'); 40 | getCurrentState.mockReturnValue(Promise.reject(error)); 41 | 42 | expect.assertions(7); 43 | await expect( 44 | toBeAtState.bind(matcherUtils)(props, expectedState), 45 | ).rejects.toBe(error); 46 | expect(getCurrentState).toHaveBeenCalledTimes(1); 47 | expect(getCurrentState).toHaveBeenCalledWith( 48 | props.region, 49 | props.stateMachineArn, 50 | ); 51 | expect(console.error).toHaveBeenCalledTimes(1); 52 | expect(console.error).toHaveBeenCalledWith( 53 | `Unknown error getting state machine state: ${error.message}`, 54 | ); 55 | expect(verifyProps).toHaveBeenCalledTimes(1); 56 | expect(verifyProps).toHaveBeenCalledWith( 57 | { ...props, state: expectedState }, 58 | ['region', 'stateMachineArn', 'state'], 59 | ); 60 | }); 61 | 62 | test('should not pass when wrong state', async () => { 63 | const { diff } = require('jest-diff'); 64 | const diffString = 'diffString'; 65 | diff.mockReturnValue(diffString); 66 | 67 | matcherUtils.equals.mockReturnValue(false); 68 | 69 | const { getCurrentState } = require('../utils/stepFunctions'); 70 | 71 | const receivedState = 'receivedState'; 72 | getCurrentState.mockReturnValue(Promise.resolve(receivedState)); 73 | 74 | const { message, pass } = await toBeAtState.bind(matcherUtils)( 75 | props, 76 | expectedState, 77 | ); 78 | 79 | expect(matcherUtils.equals).toHaveBeenCalledTimes(1); 80 | expect(matcherUtils.equals).toHaveBeenCalledWith( 81 | expectedState, 82 | receivedState, 83 | ); 84 | expect(diff).toHaveBeenCalledTimes(1); 85 | expect(diff).toHaveBeenCalledWith(expectedState, receivedState, { 86 | expand: true, 87 | }); 88 | 89 | expect(pass).toBeFalsy(); 90 | expect(message).toEqual(expect.any(Function)); 91 | expect(message()).toEqual( 92 | `.toBeAtState${EOL}${EOL}Expected ${stateMachineArn} at region ${region} to be at state ${expectedState}${EOL}` + 93 | `Difference:${EOL}${EOL}${diffString}`, 94 | ); 95 | }); 96 | 97 | test('should not pass when wrong state empty diffString', async () => { 98 | const { diff } = require('jest-diff'); 99 | const diffString = ''; 100 | diff.mockReturnValue(diffString); 101 | 102 | matcherUtils.equals.mockReturnValue(false); 103 | 104 | const { getCurrentState } = require('../utils/stepFunctions'); 105 | 106 | const receivedState = 'receivedState'; 107 | getCurrentState.mockReturnValue(Promise.resolve(receivedState)); 108 | 109 | const { message } = await toBeAtState.bind(matcherUtils)( 110 | props, 111 | expectedState, 112 | ); 113 | 114 | expect(message()).toEqual( 115 | `.toBeAtState${EOL}${EOL}Expected ${stateMachineArn} at region ${region} to be at state ${expectedState}${EOL}Difference:`, 116 | ); 117 | }); 118 | 119 | test('should pass when correct state', async () => { 120 | const { diff } = require('jest-diff'); 121 | 122 | matcherUtils.equals.mockReturnValue(true); 123 | 124 | const { getCurrentState } = require('../utils/stepFunctions'); 125 | 126 | const receivedState = expectedState; 127 | getCurrentState.mockReturnValue(Promise.resolve(receivedState)); 128 | 129 | const { message, pass } = await toBeAtState.bind(matcherUtils)( 130 | props, 131 | expectedState, 132 | ); 133 | 134 | expect(matcherUtils.equals).toHaveBeenCalledTimes(1); 135 | expect(matcherUtils.equals).toHaveBeenCalledWith( 136 | expectedState, 137 | receivedState, 138 | ); 139 | expect(diff).toHaveBeenCalledTimes(0); 140 | 141 | expect(pass).toBeTruthy(); 142 | expect(message).toEqual(expect.any(Function)); 143 | expect(message()).toEqual( 144 | `.not.toBeAtState${EOL}${EOL}Expected ${stateMachineArn} at region ${region} not to be at state ${expectedState}${EOL}`, 145 | ); 146 | }); 147 | }); 148 | 149 | describe('toHaveState', () => { 150 | beforeEach(() => { 151 | jest.clearAllMocks(); 152 | }); 153 | 154 | test('should throw error on getStates error', async () => { 155 | const { verifyProps } = require('../common'); 156 | const { getStates } = require('../utils/stepFunctions'); 157 | 158 | const error = new Error('Unknown error'); 159 | getStates.mockReturnValue(Promise.reject(error)); 160 | 161 | expect.assertions(7); 162 | await expect( 163 | toHaveState.bind(matcherUtils)(props, expectedState), 164 | ).rejects.toBe(error); 165 | expect(getStates).toHaveBeenCalledTimes(1); 166 | expect(getStates).toHaveBeenCalledWith( 167 | props.region, 168 | props.stateMachineArn, 169 | ); 170 | expect(console.error).toHaveBeenCalledTimes(1); 171 | expect(console.error).toHaveBeenCalledWith( 172 | `Unknown error getting state machine states: ${error.message}`, 173 | ); 174 | expect(verifyProps).toHaveBeenCalledTimes(1); 175 | expect(verifyProps).toHaveBeenCalledWith( 176 | { ...props, state: expectedState }, 177 | ['region', 'stateMachineArn', 'state'], 178 | ); 179 | }); 180 | 181 | test('should not pass when state is missing', async () => { 182 | const { getStates } = require('../utils/stepFunctions'); 183 | 184 | const states = ['state1', 'state2']; 185 | getStates.mockReturnValue(Promise.resolve(states)); 186 | 187 | const { message, pass } = await toHaveState.bind(matcherUtils)( 188 | props, 189 | expectedState, 190 | ); 191 | 192 | expect(pass).toBeFalsy(); 193 | expect(message).toEqual(expect.any(Function)); 194 | expect(message()).toEqual( 195 | `.toHaveState${EOL}${EOL}Expected ${stateMachineArn} at region ${region} to have state ${expectedState}${EOL}` + 196 | `Found states: ${JSON.stringify(states)}`, 197 | ); 198 | }); 199 | 200 | test('should pass when state exists', async () => { 201 | const { getStates } = require('../utils/stepFunctions'); 202 | 203 | const states = ['state1', 'state2', expectedState]; 204 | getStates.mockReturnValue(Promise.resolve(states)); 205 | 206 | const { message, pass } = await toHaveState.bind(matcherUtils)( 207 | props, 208 | expectedState, 209 | ); 210 | 211 | expect(pass).toBeTruthy(); 212 | expect(message).toEqual(expect.any(Function)); 213 | expect(message()).toEqual( 214 | `.not.toHaveState${EOL}${EOL}Expected ${stateMachineArn} at region ${region} not to have state ${expectedState}${EOL}`, 215 | ); 216 | }); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /src/jest/stepFunctions.ts: -------------------------------------------------------------------------------- 1 | import { diff } from 'jest-diff'; 2 | import { EOL } from 'os'; 3 | import { verifyProps } from '../common'; 4 | import { expectedProps, IStepFunctionsProps } from '../common/stepFunctions'; 5 | import { getCurrentState, getStates } from '../utils/stepFunctions'; 6 | 7 | export const toBeAtState = async function ( 8 | this: jest.MatcherUtils, 9 | props: IStepFunctionsProps, 10 | expected: string, 11 | ) { 12 | verifyProps({ ...props, state: expected }, expectedProps); 13 | 14 | const { region, stateMachineArn } = props; 15 | 16 | try { 17 | const printStateMachineArn = this.utils.printExpected(stateMachineArn); 18 | const printRegion = this.utils.printExpected(region); 19 | const printExpected = this.utils.printExpected(expected) + EOL; 20 | 21 | const notHint = this.utils.matcherHint('.not.toBeAtState') + EOL + EOL; 22 | const hint = this.utils.matcherHint('.toBeAtState') + EOL + EOL; 23 | 24 | const received = await getCurrentState(region, stateMachineArn); 25 | const pass = this.equals(expected, received); 26 | if (pass) { 27 | return { 28 | message: () => 29 | `${notHint}Expected ${printStateMachineArn} at region ${printRegion} not to be at state ${printExpected}`, 30 | pass: true, 31 | }; 32 | } else { 33 | const diffString = diff(expected, received, { 34 | expand: true, 35 | }); 36 | return { 37 | message: () => 38 | `${hint}Expected ${printStateMachineArn} at region ${printRegion} to be at state ${printExpected}` + 39 | `Difference:${diffString ? `${EOL}${EOL}${diffString}` : ''}`, 40 | pass: false, 41 | }; 42 | } 43 | } catch (error) { 44 | const e = error as Error; 45 | console.error(`Unknown error getting state machine state: ${e.message}`); 46 | throw e; 47 | } 48 | }; 49 | 50 | export const toHaveState = async function ( 51 | this: jest.MatcherUtils, 52 | props: IStepFunctionsProps, 53 | expected: string, 54 | ) { 55 | verifyProps({ ...props, state: expected }, expectedProps); 56 | 57 | const { region, stateMachineArn } = props; 58 | 59 | try { 60 | const printStateMachineArn = this.utils.printExpected(stateMachineArn); 61 | const printRegion = this.utils.printExpected(region); 62 | const printExpected = this.utils.printExpected(expected) + EOL; 63 | 64 | const notHint = this.utils.matcherHint('.not.toHaveState') + EOL + EOL; 65 | const hint = this.utils.matcherHint('.toHaveState') + EOL + EOL; 66 | 67 | const states = await getStates(region, stateMachineArn); 68 | const pass = states.includes(expected); 69 | if (pass) { 70 | return { 71 | message: () => 72 | `${notHint}Expected ${printStateMachineArn} at region ${printRegion} not to have state ${printExpected}`, 73 | pass: true, 74 | }; 75 | } else { 76 | return { 77 | message: () => 78 | `${hint}Expected ${printStateMachineArn} at region ${printRegion} to have state ${printExpected}` + 79 | `Found states: ${JSON.stringify(states)}`, 80 | pass: false, 81 | }; 82 | } 83 | } catch (error) { 84 | const e = error as Error; 85 | console.error(`Unknown error getting state machine states: ${e.message}`); 86 | throw e; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /src/jest/utils.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { ICommonProps } from '../common'; 3 | import { wrapWithRetries } from './utils'; 4 | 5 | jest.mock('../common'); 6 | 7 | describe('utils', () => { 8 | describe('wrapWithRetries', () => { 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | let arr = [ 14 | { pass: true, isNot: false }, 15 | { pass: false, isNot: true }, 16 | ]; 17 | arr.forEach(({ pass, isNot }) => { 18 | test(`should retry once on pass === ${pass}, isNot === ${isNot}`, async () => { 19 | const toWrap = jest.fn(); 20 | const expectedResult = { pass, message: () => '' }; 21 | toWrap.mockReturnValue(Promise.resolve(expectedResult)); 22 | 23 | const matcherUtils = { 24 | isNot, 25 | } as unknown as jest.MatcherUtils; 26 | 27 | const props = { region: 'region' } as ICommonProps; 28 | const key = 'key'; 29 | 30 | const wrapped = wrapWithRetries(toWrap); 31 | const result = await wrapped.bind(matcherUtils)(props, key); 32 | 33 | expect(toWrap).toHaveBeenCalledTimes(1); 34 | expect(toWrap).toHaveBeenCalledWith(props, key); 35 | expect(result).toBe(expectedResult); 36 | }); 37 | }); 38 | 39 | arr = [ 40 | { pass: false, isNot: false }, 41 | { pass: true, isNot: true }, 42 | ]; 43 | arr.forEach(({ pass, isNot }) => { 44 | test(`should exhaust timeout on pass === ${pass}, isNot === ${isNot}`, async () => { 45 | const { sleep } = require('../common'); 46 | 47 | const mockedNow = jest.fn(); 48 | Date.now = mockedNow; 49 | mockedNow.mockReturnValueOnce(0); 50 | mockedNow.mockReturnValueOnce(250); 51 | mockedNow.mockReturnValueOnce(500); 52 | mockedNow.mockReturnValueOnce(750); 53 | mockedNow.mockReturnValueOnce(1000); 54 | mockedNow.mockReturnValueOnce(1250); 55 | 56 | const toWrap = jest.fn(); 57 | const expectedResult = { pass, message: () => '' }; 58 | toWrap.mockReturnValue(Promise.resolve(expectedResult)); 59 | 60 | const matcherUtils = { 61 | isNot, 62 | } as unknown as jest.MatcherUtils; 63 | 64 | const props = { timeout: 1001, pollEvery: 250 } as ICommonProps; 65 | const key = 'key'; 66 | 67 | const wrapped = wrapWithRetries(toWrap); 68 | const result = await wrapped.bind(matcherUtils)(props, key); 69 | 70 | expect(toWrap).toHaveBeenCalledTimes(5); 71 | expect(toWrap).toHaveBeenCalledWith(props, key); 72 | expect(result).toBe(expectedResult); 73 | expect(sleep).toHaveBeenCalledTimes(4); 74 | expect(sleep).toHaveBeenCalledWith(props.pollEvery); 75 | }); 76 | }); 77 | 78 | test('should retry twice, { pass: false, isNot: false } => { pass: true, isNot: false }', async () => { 79 | const { sleep } = require('../common'); 80 | 81 | const mockedNow = jest.fn(); 82 | Date.now = mockedNow; 83 | mockedNow.mockReturnValueOnce(0); 84 | mockedNow.mockReturnValueOnce(250); 85 | mockedNow.mockReturnValueOnce(500); 86 | 87 | const toWrap = jest.fn(); 88 | // first attempt returns pass === false 89 | toWrap.mockReturnValueOnce( 90 | Promise.resolve({ pass: false, message: () => '' }), 91 | ); 92 | 93 | // second attempt returns pass === true 94 | const expectedResult = { pass: true, message: () => '' }; 95 | toWrap.mockReturnValueOnce(Promise.resolve(expectedResult)); 96 | 97 | const matcherUtils = { 98 | isNot: false, 99 | } as unknown as jest.MatcherUtils; 100 | 101 | const props = {} as ICommonProps; 102 | const key = 'key'; 103 | 104 | const wrapped = wrapWithRetries(toWrap); 105 | const result = await wrapped.bind(matcherUtils)(props, key); 106 | 107 | expect(toWrap).toHaveBeenCalledTimes(2); 108 | expect(toWrap).toHaveBeenCalledWith(props, key); 109 | expect(result).toBe(expectedResult); 110 | expect(sleep).toHaveBeenCalledTimes(1); 111 | expect(sleep).toHaveBeenCalledWith(500); // default pollEvery 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/jest/utils.ts: -------------------------------------------------------------------------------- 1 | import { ICommonProps, sleep } from '../common'; 2 | 3 | interface IMatchResult { 4 | pass: boolean; 5 | message: () => string; 6 | } 7 | 8 | export const wrapWithRetries = ( 9 | matcher: (this: jest.MatcherUtils, ...args: any[]) => Promise, 10 | ) => { 11 | async function wrapped( 12 | this: jest.MatcherUtils, 13 | props: ICommonProps, 14 | ...args: any[] 15 | ) { 16 | const { timeout = 2500, pollEvery = 500 } = props; 17 | 18 | const start = Date.now(); 19 | let result = await (matcher.apply(this, [ 20 | props, 21 | ...args, 22 | ]) as Promise); 23 | while (Date.now() - start < timeout) { 24 | // expecting pass === false 25 | if (this.isNot && !result.pass) { 26 | return result; 27 | } 28 | // expecting pass === true 29 | if (!this.isNot && result.pass) { 30 | return result; 31 | } 32 | 33 | // retry 34 | await sleep(pollEvery); 35 | 36 | result = await (matcher.apply(this, [ 37 | props, 38 | ...args, 39 | ]) as Promise); 40 | } 41 | return result; 42 | } 43 | return wrapped; 44 | }; 45 | -------------------------------------------------------------------------------- /src/utils/README.md: -------------------------------------------------------------------------------- 1 | ## Utils 2 | 3 | - [invoke()](#invoke) 4 | - [clearAllItems()](#clearallitems) 5 | - [writeItems()](#writeitems) 6 | - [clearAllObjects()](#clearallobjects) 7 | - [deleteAllLogs()](#deletealllogs) 8 | - [stopRunningExecutions()](#stoprunningexecutions) 9 | - [deploy()](#deploy) 10 | 11 | ### `invoke()` 12 | 13 | Invokes a lambda function 14 | 15 | ```typescript 16 | const { invoke } = require('aws-testing-library/lib/utils/lambda'); 17 | 18 | const result = await invoke( 19 | 'us-east-1', 20 | 'functionName', 21 | { 22 | body: JSON.stringify({ text: 'from e2e test' }), 23 | } /* optional: payload for the lambda */, 24 | ); 25 | ``` 26 | 27 | ### `clearAllItems()` 28 | 29 | Clear all items in a DynamoDb table 30 | 31 | ```typescript 32 | const { clearAllItems } = require('aws-testing-library/lib/utils/dynamoDb'); 33 | 34 | await clearAllItems('us-east-1', 'dynamo-db-table'); 35 | ``` 36 | 37 | ### `writeItems()` 38 | 39 | Write items to a DynamoDb table 40 | 41 | ```typescript 42 | const { writeItems } = require('aws-testing-library/lib/utils/dynamoDb'); 43 | 44 | const items = require('./seed.json'); 45 | 46 | await writeItems('us-east-1', 'dynamo-db-table', items); 47 | ``` 48 | 49 | ### `clearAllObjects()` 50 | 51 | Clear all objects in a s3 bucket 52 | 53 | ```typescript 54 | const { clearAllObjects } = require('aws-testing-library/lib/utils/s3'); 55 | 56 | await clearAllObjects( 57 | 'us-east-1', 58 | 's3-bucket', 59 | 'key-prefix' /* optional, only delete objects with keys that begin with the specified prefix*/, 60 | ); 61 | ``` 62 | 63 | ### `deleteAllLogs()` 64 | 65 | Clear all log streams for a lambda function 66 | 67 | ```typescript 68 | const { deleteAllLogs } = require('aws-testing-library/lib/utils/cloudwatch'); 69 | 70 | await deleteAllLogs('us-east-1', 'lambda-function-name'); 71 | ``` 72 | 73 | ### `stopRunningExecutions()` 74 | 75 | Stop all running executions for a state machine 76 | 77 | ```typescript 78 | const { 79 | stopRunningExecutions, 80 | } = require('aws-testing-library/lib/utils/stepFunctions'); 81 | 82 | await stopRunningExecutions('us-east-1', 'state-machine-arn'); 83 | ``` 84 | 85 | ### `deploy()` 86 | 87 | Deploys the current service using [Serverless framework](https://serverless.com/) 88 | 89 | ```typescript 90 | const { deploy } = require('aws-testing-library/lib/utils/serverless'); 91 | 92 | await deploy('dev' /* optional - deployment stage */); 93 | ``` 94 | -------------------------------------------------------------------------------- /src/utils/api.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { getResponse } from './api'; 3 | 4 | jest.mock('axios'); 5 | 6 | describe('api utils', () => { 7 | test('should call axios with relevant parameters', async () => { 8 | const axios = require('axios'); 9 | 10 | const expected = { status: 500, data: { message: 'Hello World!' } }; 11 | axios.mockReturnValue(Promise.resolve(expected)); 12 | 13 | const url = 'url'; 14 | const method = 'POST'; 15 | const params = { param1: 'param1' }; 16 | const data = { data1: 'data1' }; 17 | const headers = { header1: 'header1' }; 18 | 19 | const result = await getResponse(url, method, params, data, headers); 20 | 21 | expect(axios).toHaveBeenCalledTimes(1); 22 | expect(axios).toHaveBeenCalledWith({ 23 | data, 24 | headers, 25 | method, 26 | params, 27 | timeout: 30 * 1000, 28 | url, 29 | validateStatus: expect.any(Function), 30 | }); 31 | 32 | expect(result).toEqual({ 33 | data: expected.data, 34 | statusCode: expected.status, 35 | }); 36 | 37 | expect(axios.mock.calls[0][0].validateStatus()).toBe(true); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/utils/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig, Method } from 'axios'; 2 | 3 | export type PlainObject = Record; 4 | 5 | export const getResponse = async ( 6 | url: string, 7 | method: Method, 8 | params?: PlainObject, 9 | data?: PlainObject, 10 | headers?: PlainObject, 11 | ) => { 12 | const config: AxiosRequestConfig = { 13 | data, 14 | headers, 15 | method, 16 | params, 17 | timeout: 30 * 1000, 18 | url, 19 | validateStatus: () => true, // accept any status code 20 | }; 21 | 22 | const result = await axios(config); 23 | return { statusCode: result.status, data: result.data }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/cloudwatch.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { deleteAllLogs, filterLogEvents as getEvents } from './cloudwatch'; 3 | 4 | jest.mock('aws-sdk', () => { 5 | const deleteLogStreamValue = { promise: jest.fn() }; 6 | const deleteLogStream = jest.fn(() => deleteLogStreamValue); 7 | const describeLogStreamsValue = { promise: jest.fn() }; 8 | const describeLogStreams = jest.fn(() => describeLogStreamsValue); 9 | const filterLogEventsValue = { promise: jest.fn() }; 10 | const filterLogEvents = jest.fn(() => filterLogEventsValue); 11 | const CloudWatchLogs = jest.fn(() => ({ 12 | deleteLogStream, 13 | describeLogStreams, 14 | filterLogEvents, 15 | })); 16 | return { CloudWatchLogs }; 17 | }); 18 | 19 | describe('cloudwatch utils', () => { 20 | const AWS = require('aws-sdk'); 21 | const cloudWatchLogs = AWS.CloudWatchLogs; 22 | 23 | const [region, logGroupName] = ['region', `/aws/lambda/functionName`]; 24 | 25 | describe('deleteAllLogs', () => { 26 | test('should not call deleteLogStream on no log streams', async () => { 27 | const describeLogStreams = cloudWatchLogs().describeLogStreams; 28 | const deleteLogStream = cloudWatchLogs().deleteLogStream; 29 | const promise = describeLogStreams().promise; 30 | promise.mockReturnValue(Promise.resolve({ logStreams: undefined })); 31 | 32 | jest.clearAllMocks(); 33 | 34 | await deleteAllLogs(region, 'functionName'); 35 | 36 | expect(cloudWatchLogs).toHaveBeenCalledTimes(1); 37 | expect(cloudWatchLogs).toHaveBeenCalledWith({ region }); 38 | expect(describeLogStreams).toHaveBeenCalledTimes(1); 39 | expect(describeLogStreams).toHaveBeenCalledWith({ 40 | descending: true, 41 | logGroupName, 42 | orderBy: 'LastEventTime', 43 | }); 44 | expect(deleteLogStream).toHaveBeenCalledTimes(0); 45 | }); 46 | 47 | test('should call deleteLogStream on log streams', async () => { 48 | const describeLogStreams = cloudWatchLogs().describeLogStreams; 49 | const deleteLogStream = cloudWatchLogs().deleteLogStream; 50 | const promise = describeLogStreams().promise; 51 | 52 | const logStreams = [ 53 | { logStreamName: 'logStreamName1' }, 54 | { logStreamName: '' }, 55 | ]; 56 | promise.mockReturnValue(Promise.resolve({ logStreams })); 57 | 58 | jest.clearAllMocks(); 59 | 60 | await deleteAllLogs(region, 'functionName'); 61 | 62 | expect(deleteLogStream).toHaveBeenCalledTimes(logStreams.length); 63 | 64 | logStreams.forEach(({ logStreamName }) => { 65 | expect(deleteLogStream).toHaveBeenCalledWith({ 66 | logGroupName, 67 | logStreamName, 68 | }); 69 | }); 70 | }); 71 | }); 72 | 73 | describe('filterLogEvents', () => { 74 | test('should return log events', async () => { 75 | const filterLogEvents = cloudWatchLogs().filterLogEvents; 76 | const promise = filterLogEvents().promise; 77 | const events = ['event1', 'event2']; 78 | promise.mockReturnValue(Promise.resolve({ events })); 79 | 80 | jest.clearAllMocks(); 81 | 82 | const startTime = 12 * 60 * 60 * 1000; 83 | const filterPattern = 'filterPattern'; 84 | const actual = await getEvents( 85 | region, 86 | logGroupName, 87 | startTime, 88 | filterPattern, 89 | ); 90 | 91 | expect(cloudWatchLogs).toHaveBeenCalledTimes(1); 92 | expect(cloudWatchLogs).toHaveBeenCalledWith({ region }); 93 | expect(filterLogEvents).toHaveBeenCalledTimes(1); 94 | expect(filterLogEvents).toHaveBeenCalledWith({ 95 | filterPattern, 96 | interleaved: true, 97 | limit: 1, 98 | logGroupName, 99 | startTime, 100 | }); 101 | expect(actual).toEqual({ events }); 102 | }); 103 | 104 | test('should return empty array on undefined events', async () => { 105 | const filterLogEvents = cloudWatchLogs().filterLogEvents; 106 | const promise = filterLogEvents().promise; 107 | promise.mockReturnValue(Promise.resolve({})); 108 | 109 | jest.clearAllMocks(); 110 | 111 | const startTime = 12 * 60 * 60 * 1000; 112 | const filterPattern = 'filterPattern'; 113 | const actual = await getEvents( 114 | region, 115 | logGroupName, 116 | startTime, 117 | filterPattern, 118 | ); 119 | 120 | expect(actual).toEqual({ events: [] }); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/utils/cloudwatch.ts: -------------------------------------------------------------------------------- 1 | import AWS = require('aws-sdk'); 2 | 3 | export const getLogGroupName = (functionName: string) => 4 | `/aws/lambda/${functionName}`; 5 | 6 | export const filterLogEvents = async ( 7 | region: string, 8 | logGroupName: string, 9 | startTime: number, 10 | filterPattern: string, 11 | ) => { 12 | const cloudWatchLogs = new AWS.CloudWatchLogs({ region }); 13 | 14 | const { events = [] } = await cloudWatchLogs 15 | .filterLogEvents({ 16 | filterPattern, 17 | interleaved: true, 18 | limit: 1, 19 | logGroupName, 20 | startTime, 21 | }) 22 | .promise(); 23 | 24 | return { events }; 25 | }; 26 | 27 | const getLogStreams = async (region: string, functionName: string) => { 28 | const cloudWatchLogs = new AWS.CloudWatchLogs({ region }); 29 | const logGroupName = getLogGroupName(functionName); 30 | 31 | const { logStreams = [] } = await cloudWatchLogs 32 | .describeLogStreams({ 33 | descending: true, 34 | logGroupName, 35 | orderBy: 'LastEventTime', 36 | }) 37 | .promise(); 38 | 39 | return { logStreams }; 40 | }; 41 | 42 | export const deleteAllLogs = async (region: string, functionName: string) => { 43 | const { logStreams } = await getLogStreams(region, functionName); 44 | if (logStreams.length <= 0) { 45 | return; 46 | } 47 | const cloudWatchLogs = new AWS.CloudWatchLogs({ region }); 48 | const logGroupName = getLogGroupName(functionName); 49 | 50 | const logStreamNames = logStreams.map((s) => s.logStreamName || ''); 51 | 52 | await Promise.all( 53 | logStreamNames.map((logStreamName) => { 54 | return cloudWatchLogs 55 | .deleteLogStream({ logGroupName, logStreamName }) 56 | .promise(); 57 | }), 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/utils/dynamoDb.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { clearAllItems, getItem, writeItems } from './dynamoDb'; 3 | 4 | jest.mock('aws-sdk', () => { 5 | const scanValue = { promise: jest.fn() }; 6 | const scan = jest.fn(() => scanValue); 7 | const batchWriteValue = { promise: jest.fn() }; 8 | const batchWrite = jest.fn(() => batchWriteValue); 9 | const getValue = { promise: jest.fn() }; 10 | const get = jest.fn(() => getValue); 11 | const DocumentClient = jest.fn(() => ({ scan, batchWrite, get })); 12 | const describeTableValue = { promise: jest.fn() }; 13 | const describeTable = jest.fn(() => describeTableValue); 14 | const DynamoDB = jest.fn(() => ({ describeTable })) as any; 15 | DynamoDB.DocumentClient = DocumentClient; 16 | return { DynamoDB }; 17 | }); 18 | 19 | describe('dynamoDb utils', () => { 20 | const AWS = require('aws-sdk'); 21 | const db = AWS.DynamoDB; 22 | const documentClient = AWS.DynamoDB.DocumentClient; 23 | 24 | const [region, tableName] = ['region', 'tableName']; 25 | 26 | describe('clearAllItems', () => { 27 | test('should not call batchWrite on empty db', async () => { 28 | const describeTable = db().describeTable; 29 | const describeTablePromise = describeTable().promise; 30 | describeTablePromise.mockReturnValue(Promise.resolve({})); 31 | 32 | const scan = documentClient().scan; 33 | const batchWrite = documentClient().batchWrite; 34 | const scanPromise = scan().promise; 35 | scanPromise.mockReturnValue(Promise.resolve({ Items: undefined })); 36 | 37 | jest.clearAllMocks(); 38 | 39 | await clearAllItems(region, tableName); 40 | 41 | expect(db).toHaveBeenCalledTimes(1); 42 | expect(db).toHaveBeenCalledWith({ region }); 43 | expect(describeTable).toHaveBeenCalledTimes(1); 44 | expect(describeTable).toHaveBeenCalledWith({ 45 | TableName: tableName, 46 | }); 47 | expect(documentClient).toHaveBeenCalledTimes(1); 48 | expect(documentClient).toHaveBeenCalledWith({ region }); 49 | expect(scan).toHaveBeenCalledTimes(1); 50 | expect(scan).toHaveBeenCalledWith({ 51 | AttributesToGet: [], 52 | TableName: tableName, 53 | }); 54 | expect(batchWrite).toHaveBeenCalledTimes(0); 55 | }); 56 | 57 | test('should call batchWrite on non empty db', async () => { 58 | const describeTable = db().describeTable; 59 | const describeTablePromise = describeTable().promise; 60 | const table = { KeySchema: [{ AttributeName: 'id' }] }; 61 | describeTablePromise.mockReturnValue(Promise.resolve({ Table: table })); 62 | 63 | const scan = documentClient().scan; 64 | const batchWrite = documentClient().batchWrite; 65 | const scanPromise = scan().promise; 66 | const items = [{ id: 'id1' }, { id: 'id2' }]; 67 | scanPromise.mockReturnValue(Promise.resolve({ Items: items })); 68 | 69 | jest.clearAllMocks(); 70 | 71 | await clearAllItems(region, tableName); 72 | 73 | expect(scan).toHaveBeenCalledTimes(1); 74 | expect(scan).toHaveBeenCalledWith({ 75 | AttributesToGet: table.KeySchema.map((k) => k.AttributeName), 76 | TableName: tableName, 77 | }); 78 | 79 | expect(batchWrite).toHaveBeenCalledTimes(1); 80 | expect(batchWrite).toHaveBeenCalledWith({ 81 | RequestItems: { 82 | [tableName]: items.map((item) => ({ 83 | DeleteRequest: { Key: { id: item.id } }, 84 | })), 85 | }, 86 | }); 87 | }); 88 | test('should call batchWrite multiple times for db containing more than 25 items', async () => { 89 | const describeTable = db().describeTable; 90 | const describeTablePromise = describeTable().promise; 91 | const table = { KeySchema: [{ AttributeName: 'id' }] }; 92 | describeTablePromise.mockReturnValue(Promise.resolve({ Table: table })); 93 | 94 | const scan = documentClient().scan; 95 | const batchWrite = documentClient().batchWrite; 96 | const scanPromise = scan().promise; 97 | const items = Array(30) 98 | .fill({ id: 'id' }) 99 | .map((el, i) => ({ 100 | id: el.id + i, 101 | })); 102 | scanPromise.mockReturnValue(Promise.resolve({ Items: items })); 103 | 104 | jest.clearAllMocks(); 105 | 106 | await clearAllItems(region, tableName); 107 | 108 | expect(scan).toHaveBeenCalledTimes(1); 109 | expect(scan).toHaveBeenCalledWith({ 110 | AttributesToGet: table.KeySchema.map((k) => k.AttributeName), 111 | TableName: tableName, 112 | }); 113 | 114 | expect(batchWrite).toHaveBeenCalledTimes(2); 115 | const batch1 = items.splice(0, 25); 116 | const batch2 = items; 117 | expect(batch1.length).toBe(25); 118 | expect(batch2.length).toBe(5); 119 | expect(batchWrite).toHaveBeenCalledWith({ 120 | RequestItems: { 121 | [tableName]: batch1.map((item) => ({ 122 | DeleteRequest: { Key: { id: item.id } }, 123 | })), 124 | }, 125 | }); 126 | expect(batchWrite).toHaveBeenCalledWith({ 127 | RequestItems: { 128 | [tableName]: batch2.map((item) => ({ 129 | DeleteRequest: { Key: { id: item.id } }, 130 | })), 131 | }, 132 | }); 133 | }); 134 | }); 135 | 136 | describe('writeItems', () => { 137 | test('should call batchWrite on writeItems', async () => { 138 | const items = [{ id: 'id1' }, { id: 'id2' }]; 139 | const batchWrite = documentClient().batchWrite; 140 | const promise = batchWrite().promise; 141 | 142 | jest.clearAllMocks(); 143 | 144 | await writeItems(region, tableName, items); 145 | 146 | const writeRequests = items.map((item) => ({ 147 | PutRequest: { Item: item }, 148 | })); 149 | 150 | expect(batchWrite).toHaveBeenCalledTimes(1); 151 | expect(batchWrite).toHaveBeenCalledWith({ 152 | RequestItems: { [tableName]: writeRequests }, 153 | }); 154 | expect(promise).toHaveBeenCalledTimes(1); 155 | }); 156 | }); 157 | 158 | describe('getItem', () => { 159 | test('should return item', async () => { 160 | const get = documentClient().get; 161 | const promise = get().promise; 162 | const item = { Item: { id: 'id' } }; 163 | promise.mockReturnValue(Promise.resolve(item)); 164 | 165 | jest.clearAllMocks(); 166 | 167 | const key = { id: 'id' }; 168 | const actual = await getItem(region, tableName, key); 169 | 170 | expect(documentClient).toHaveBeenCalledTimes(1); 171 | expect(documentClient).toHaveBeenCalledWith({ region }); 172 | expect(get).toHaveBeenCalledTimes(1); 173 | expect(get).toHaveBeenCalledWith({ TableName: tableName, Key: key }); 174 | expect(actual).toEqual(item.Item); 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /src/utils/dynamoDb.ts: -------------------------------------------------------------------------------- 1 | import AWS = require('aws-sdk'); 2 | 3 | function* chunks(arr: T[], n: number): Generator { 4 | for (let i = 0; i < arr.length; i += n) { 5 | yield arr.slice(i, i + n); 6 | } 7 | } 8 | 9 | const itemToKey = ( 10 | item: AWS.DynamoDB.DocumentClient.AttributeMap, 11 | keySchema: AWS.DynamoDB.KeySchemaElement[], 12 | ) => { 13 | let itemKey: AWS.DynamoDB.DocumentClient.Key = {}; 14 | keySchema.map((key) => { 15 | itemKey = { ...itemKey, [key.AttributeName]: item[key.AttributeName] }; 16 | }); 17 | return itemKey; 18 | }; 19 | 20 | export const clearAllItems = async (region: string, tableName: string) => { 21 | // get the table keys 22 | const table = new AWS.DynamoDB({ region }); 23 | const { Table = {} } = await table 24 | .describeTable({ TableName: tableName }) 25 | .promise(); 26 | 27 | const keySchema = Table.KeySchema || []; 28 | 29 | // get the items to delete 30 | const db = new AWS.DynamoDB.DocumentClient({ region }); 31 | const scanResult = await db 32 | .scan({ 33 | AttributesToGet: keySchema.map((key) => key.AttributeName), 34 | TableName: tableName, 35 | }) 36 | .promise(); 37 | const items = scanResult.Items || []; 38 | 39 | if (items.length > 0) { 40 | for (const chunk of chunks(items, 25)) { 41 | const deleteRequests = chunk.map((item) => ({ 42 | DeleteRequest: { Key: itemToKey(item, keySchema) }, 43 | })); 44 | 45 | await db 46 | .batchWrite({ RequestItems: { [tableName]: deleteRequests } }) 47 | .promise(); 48 | } 49 | } 50 | }; 51 | 52 | export const writeItems = async ( 53 | region: string, 54 | tableName: string, 55 | items: AWS.DynamoDB.DocumentClient.PutItemInputAttributeMap[], 56 | ) => { 57 | const db = new AWS.DynamoDB.DocumentClient({ region }); 58 | const writeRequests = items.map((item) => ({ 59 | PutRequest: { Item: item }, 60 | })); 61 | 62 | await db 63 | .batchWrite({ RequestItems: { [tableName]: writeRequests } }) 64 | .promise(); 65 | }; 66 | 67 | export const getItem = async ( 68 | region: string, 69 | tableName: string, 70 | key: AWS.DynamoDB.DocumentClient.Key, 71 | ) => { 72 | const db = new AWS.DynamoDB.DocumentClient({ region }); 73 | const dbItem = await db.get({ TableName: tableName, Key: key }).promise(); 74 | // Item is undefined if key not found 75 | return dbItem.Item; 76 | }; 77 | -------------------------------------------------------------------------------- /src/utils/kinesis.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import MockDate = require('mockdate'); 3 | import { existsInStream } from './kinesis'; 4 | 5 | jest.mock('aws-sdk', () => { 6 | const getRecordsValue = { promise: jest.fn() }; 7 | const getRecords = jest.fn(() => getRecordsValue); 8 | 9 | const getShardIteratorValue = { promise: jest.fn() }; 10 | const getShardIterator = jest.fn(() => getShardIteratorValue); 11 | 12 | const describeStreamValue = { promise: jest.fn() }; 13 | const describeStream = jest.fn(() => describeStreamValue); 14 | 15 | const Kinesis = jest.fn(() => ({ 16 | describeStream, 17 | getRecords, 18 | getShardIterator, 19 | })); 20 | return { Kinesis }; 21 | }); 22 | 23 | describe('kinesis utils', () => { 24 | const AWS = require('aws-sdk'); 25 | const kinesis = AWS.Kinesis; 26 | 27 | const [region, stream, matcher, timeout] = [ 28 | 'region', 29 | 'stream', 30 | jest.fn(), 31 | 5000, 32 | ]; 33 | 34 | afterEach(() => { 35 | MockDate.reset(); 36 | }); 37 | 38 | test('existsInStream item found', async () => { 39 | MockDate.set('1948/1/1'); 40 | 41 | const getRecords = kinesis().getRecords; 42 | const getRecordsPromise = getRecords().promise; 43 | 44 | const record1 = { id: 'data1' }; 45 | const records = [{ Data: JSON.stringify(record1) }]; 46 | getRecordsPromise.mockReturnValue({ 47 | NextShardIterator: null, 48 | Records: records, 49 | }); 50 | 51 | const getShardIterator = kinesis().getShardIterator; 52 | const getShardIteratorPromise = getShardIterator().promise; 53 | 54 | const shardIterators = [ 55 | { 56 | ShardIterator: 'iterator0001', 57 | }, 58 | ]; 59 | shardIterators.forEach((s) => 60 | getShardIteratorPromise.mockReturnValueOnce(s), 61 | ); 62 | 63 | const describeStream = kinesis().describeStream; 64 | const describeStreamPromise = describeStream().promise; 65 | 66 | const streamDescription = { 67 | StreamDescription: { 68 | Shards: [{ ShardId: '0001' }], 69 | }, 70 | }; 71 | describeStreamPromise.mockReturnValue(Promise.resolve(streamDescription)); 72 | 73 | matcher.mockReturnValue(true); 74 | 75 | jest.clearAllMocks(); 76 | 77 | const exists = await existsInStream(region, stream, matcher, timeout); 78 | 79 | expect(exists).toBe(true); 80 | 81 | expect(describeStream).toHaveBeenCalledTimes(1); 82 | expect(describeStreamPromise).toHaveBeenCalledTimes(1); 83 | 84 | expect(describeStream).toHaveBeenCalledWith({ StreamName: stream }); 85 | 86 | expect(getShardIterator).toHaveBeenCalledTimes(1); 87 | expect(getShardIteratorPromise).toHaveBeenCalledTimes(1); 88 | 89 | expect(getShardIterator).toHaveBeenCalledWith({ 90 | ShardId: streamDescription.StreamDescription.Shards[0].ShardId, 91 | ShardIteratorType: 'AT_TIMESTAMP', 92 | StreamName: stream, 93 | Timestamp: new Date(Date.now() - 1000 * 60 * 5), 94 | }); 95 | 96 | expect(getRecords).toHaveBeenCalledTimes(1); 97 | expect(getRecordsPromise).toHaveBeenCalledTimes(1); 98 | 99 | expect(getRecords).toHaveBeenCalledWith({ 100 | ShardIterator: shardIterators[0].ShardIterator, 101 | }); 102 | 103 | expect(matcher).toHaveBeenCalledTimes(1); 104 | expect(matcher).toHaveBeenCalledWith(record1, 0, [record1]); 105 | }); 106 | 107 | test('existsInStream item not found due to null NextShardIterator', async () => { 108 | const getRecords = kinesis().getRecords; 109 | const getRecordsPromise = getRecords().promise; 110 | 111 | const record1 = { id: 'data1' }; 112 | const records = [{ Data: JSON.stringify(record1) }]; 113 | getRecordsPromise.mockReturnValue({ 114 | NextShardIterator: null, 115 | Records: records, 116 | }); 117 | 118 | const getShardIterator = kinesis().getShardIterator; 119 | const getShardIteratorPromise = getShardIterator().promise; 120 | 121 | const shardIterators = [ 122 | { 123 | ShardIterator: 'iterator0001', 124 | }, 125 | ]; 126 | shardIterators.forEach((s) => 127 | getShardIteratorPromise.mockReturnValueOnce(s), 128 | ); 129 | 130 | const describeStream = kinesis().describeStream; 131 | const describeStreamPromise = describeStream().promise; 132 | 133 | const streamDescription = { 134 | StreamDescription: { 135 | Shards: [{ ShardId: '0001' }], 136 | }, 137 | }; 138 | describeStreamPromise.mockReturnValue(Promise.resolve(streamDescription)); 139 | 140 | matcher.mockReturnValue(false); 141 | 142 | jest.clearAllMocks(); 143 | 144 | const exists = await existsInStream(region, stream, matcher, timeout); 145 | 146 | expect(exists).toBe(false); 147 | expect(getRecords).toHaveBeenCalledTimes(1); 148 | expect(matcher).toHaveBeenCalledTimes(1); 149 | }); 150 | 151 | test('existsInStream item not found due to null ShardIterator', async () => { 152 | const getRecords = kinesis().getRecords; 153 | const getShardIterator = kinesis().getShardIterator; 154 | const getShardIteratorPromise = getShardIterator().promise; 155 | 156 | const shardIterators = [ 157 | { 158 | ShardIterator: null, 159 | }, 160 | ]; 161 | shardIterators.forEach((s) => 162 | getShardIteratorPromise.mockReturnValueOnce(s), 163 | ); 164 | 165 | const describeStream = kinesis().describeStream; 166 | const describeStreamPromise = describeStream().promise; 167 | 168 | const streamDescription = { 169 | StreamDescription: { 170 | Shards: [{ ShardId: '0001' }], 171 | }, 172 | }; 173 | describeStreamPromise.mockReturnValue(Promise.resolve(streamDescription)); 174 | 175 | jest.clearAllMocks(); 176 | 177 | const exists = await existsInStream(region, stream, matcher, timeout); 178 | 179 | expect(exists).toBe(false); 180 | expect(getRecords).toHaveBeenCalledTimes(0); 181 | expect(matcher).toHaveBeenCalledTimes(0); 182 | }); 183 | 184 | test('existsInStream item not found due to timeout', async () => { 185 | const getRecords = kinesis().getRecords; 186 | const getRecordsPromise = getRecords().promise; 187 | 188 | const record1 = { id: 'data1' }; 189 | const records = [{ Data: JSON.stringify(record1) }]; 190 | getRecordsPromise.mockReturnValue({ 191 | NextShardIterator: 'iterator0002', 192 | Records: records, 193 | }); 194 | 195 | const getShardIterator = kinesis().getShardIterator; 196 | const getShardIteratorPromise = getShardIterator().promise; 197 | 198 | const shardIterators = [ 199 | { 200 | ShardIterator: 'iterator0001', 201 | }, 202 | ]; 203 | shardIterators.forEach((s) => 204 | getShardIteratorPromise.mockReturnValueOnce(s), 205 | ); 206 | 207 | const describeStream = kinesis().describeStream; 208 | const describeStreamPromise = describeStream().promise; 209 | 210 | const streamDescription = { 211 | StreamDescription: { 212 | Shards: [{ ShardId: '0001' }], 213 | }, 214 | }; 215 | describeStreamPromise.mockReturnValue(Promise.resolve(streamDescription)); 216 | 217 | matcher.mockReturnValue(false); 218 | 219 | jest.clearAllMocks(); 220 | 221 | const exists = await existsInStream(region, stream, matcher, 50, 10); 222 | 223 | expect(exists).toBe(false); 224 | expect(getRecords.mock.calls.length).toBeGreaterThan(1); 225 | expect(matcher.mock.calls.length).toBeGreaterThan(1); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /src/utils/kinesis.ts: -------------------------------------------------------------------------------- 1 | import { Kinesis } from 'aws-sdk'; 2 | 3 | export type IRecordMatcher = (args: any) => boolean; 4 | 5 | const getRecords = async (kinesis: Kinesis, shardIterator: string) => { 6 | const { NextShardIterator: nextShardIterator, Records: records } = 7 | await kinesis.getRecords({ ShardIterator: shardIterator }).promise(); 8 | 9 | const data = records.map((r) => JSON.parse(r.Data.toString())); 10 | 11 | return { nextShardIterator, data }; 12 | }; 13 | 14 | const sleep = async (time: number) => { 15 | return await new Promise((resolve) => setTimeout(resolve, time)); 16 | }; 17 | 18 | export const existsInShard = async ( 19 | kinesis: Kinesis, 20 | shardIterator: string | undefined, 21 | matcher: IRecordMatcher, 22 | timeout: number, 23 | pollEvery: number, 24 | ) => { 25 | if (!shardIterator) { 26 | return false; 27 | } 28 | 29 | let found = false; 30 | let timeoutReached = false; 31 | const start = new Date().getTime(); 32 | 33 | let nextShardIterator = shardIterator; 34 | do { 35 | const records = await getRecords(kinesis, nextShardIterator); 36 | const data = records.data; 37 | 38 | const index = data.findIndex(matcher); 39 | if (index >= 0) { 40 | found = true; 41 | break; 42 | } 43 | 44 | const currentTime = new Date().getTime(); 45 | timeoutReached = currentTime - start >= timeout; 46 | if (timeoutReached) { 47 | break; 48 | } 49 | 50 | nextShardIterator = records.nextShardIterator || ''; 51 | 52 | if (nextShardIterator) { 53 | // throttle the consumer 54 | await sleep(pollEvery); 55 | } 56 | } while (nextShardIterator); 57 | 58 | return found; 59 | }; 60 | 61 | export const existsInStream = async ( 62 | region: string, 63 | stream: string, 64 | matcher: IRecordMatcher, 65 | timeout: number, 66 | pollEvery = 500, 67 | ) => { 68 | const kinesis = new Kinesis({ region }); 69 | const streamDescription = await kinesis 70 | .describeStream({ StreamName: stream }) 71 | .promise(); 72 | 73 | const shardIterators = await Promise.all( 74 | // search in all shards 75 | streamDescription.StreamDescription.Shards.map((s) => { 76 | return kinesis 77 | .getShardIterator({ 78 | ShardId: s.ShardId, 79 | ShardIteratorType: 'AT_TIMESTAMP', 80 | StreamName: stream, 81 | Timestamp: new Date(Date.now() - 1000 * 60 * 5), // start searching from 5 minutes ago 82 | }) 83 | .promise(); 84 | }), 85 | ); 86 | 87 | const results = await Promise.all( 88 | shardIterators.map((s) => 89 | existsInShard(kinesis, s.ShardIterator, matcher, timeout, pollEvery), 90 | ), 91 | ); 92 | const exists = results.some((r) => r); 93 | return exists; 94 | }; 95 | -------------------------------------------------------------------------------- /src/utils/lambda.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { invoke } from './lambda'; 3 | 4 | jest.mock('aws-sdk', () => { 5 | const promise = jest.fn(); 6 | const invokeValue = { promise }; 7 | const lambdaInvoke = jest.fn(() => invokeValue); 8 | const Lambda = jest.fn(() => ({ invoke: lambdaInvoke })); 9 | return { Lambda }; 10 | }); 11 | 12 | describe('lambda utils', () => { 13 | const AWS = require('aws-sdk'); 14 | const lambda = AWS.Lambda; 15 | 16 | const [region, functionName] = ['region', 'functionName']; 17 | 18 | describe('invoke', () => { 19 | test('invoke should return parsed payload', async () => { 20 | const lambdaInvoke = lambda().invoke; 21 | const promise = lambdaInvoke().promise; 22 | 23 | const payload = { 24 | body: 'some return value', 25 | headers: { someHeader: 'someHeader' }, 26 | }; 27 | const expected = { 28 | Payload: JSON.stringify(payload), 29 | }; 30 | promise.mockReturnValue(Promise.resolve(expected)); 31 | 32 | jest.clearAllMocks(); 33 | 34 | const actual = await invoke(region, functionName, payload); 35 | 36 | expect(actual).toEqual(payload); 37 | expect(lambda).toHaveBeenCalledTimes(1); 38 | expect(lambda).toHaveBeenCalledWith({ region }); 39 | expect(lambdaInvoke).toHaveBeenCalledTimes(1); 40 | expect(lambdaInvoke).toHaveBeenCalledWith({ 41 | FunctionName: functionName, 42 | Payload: JSON.stringify(payload), 43 | }); 44 | }); 45 | 46 | test('invoke should return undefined', async () => { 47 | const lambdaInvoke = lambda().invoke; 48 | const promise = lambdaInvoke().promise; 49 | 50 | promise.mockReturnValue(Promise.resolve({})); 51 | 52 | jest.clearAllMocks(); 53 | 54 | const actual = await invoke(region, functionName); 55 | 56 | expect(actual).toBeUndefined(); 57 | expect(lambda).toHaveBeenCalledTimes(1); 58 | expect(lambda).toHaveBeenCalledWith({ region }); 59 | expect(lambdaInvoke).toHaveBeenCalledTimes(1); 60 | expect(lambdaInvoke).toHaveBeenCalledWith({ 61 | FunctionName: functionName, 62 | }); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/utils/lambda.ts: -------------------------------------------------------------------------------- 1 | import AWS = require('aws-sdk'); 2 | 3 | export const invoke = async ( 4 | region: string, 5 | functionName: string, 6 | payload?: any, 7 | ) => { 8 | const lambda = new AWS.Lambda({ region }); 9 | 10 | const lambdaPayload = payload ? { Payload: JSON.stringify(payload) } : {}; 11 | const params = { 12 | FunctionName: functionName, 13 | ...lambdaPayload, 14 | }; 15 | 16 | const { Payload } = await lambda.invoke(params).promise(); 17 | if (Payload) { 18 | return JSON.parse(Payload.toString()); 19 | } else { 20 | return undefined; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/s3.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { clearAllObjects, getObject as getS3Object } from './s3'; 3 | 4 | jest.mock('aws-sdk', () => { 5 | const listObjectsV2Value = { promise: jest.fn() }; 6 | const listObjectsV2 = jest.fn(() => listObjectsV2Value); 7 | const getObjectValue = { promise: jest.fn() }; 8 | const getObject = jest.fn(() => getObjectValue); 9 | const deleteObjectsValue = { promise: jest.fn() }; 10 | const deleteObjects = jest.fn(() => deleteObjectsValue); 11 | const S3 = jest.fn(() => ({ listObjectsV2, getObject, deleteObjects })); 12 | return { S3 }; 13 | }); 14 | 15 | describe('s3 utils', () => { 16 | const AWS = require('aws-sdk'); 17 | const s3 = AWS.S3; 18 | 19 | const [region, bucket] = ['region', 'bucket']; 20 | 21 | describe('clearAllObjects', () => { 22 | test('should not call deleteObjects on empty bucket', async () => { 23 | const listObjectsV2 = s3().listObjectsV2; 24 | const deleteObjects = s3().deleteObjects; 25 | const promise = listObjectsV2().promise; 26 | promise.mockReturnValue(Promise.resolve({ Contents: undefined })); 27 | 28 | jest.clearAllMocks(); 29 | 30 | await clearAllObjects(region, bucket); 31 | 32 | expect(s3).toHaveBeenCalledTimes(1); 33 | expect(s3).toHaveBeenCalledWith({ region }); 34 | expect(listObjectsV2).toHaveBeenCalledTimes(1); 35 | expect(listObjectsV2).toHaveBeenCalledWith({ 36 | Bucket: bucket, 37 | ContinuationToken: undefined, 38 | }); 39 | expect(deleteObjects).toHaveBeenCalledTimes(0); 40 | }); 41 | 42 | test('should call deleteObjects on non empty bucket', async () => { 43 | const listObjectsV2 = s3().listObjectsV2; 44 | const deleteObjects = s3().deleteObjects; 45 | const promise = listObjectsV2().promise; 46 | const firstItems = ['key1', 'key2'].map((Key) => ({ Key })); 47 | const nextContinuationToken = 'NextContinuationToken'; 48 | promise.mockReturnValueOnce( 49 | Promise.resolve({ 50 | Contents: firstItems, 51 | IsTruncated: true, 52 | NextContinuationToken: nextContinuationToken, 53 | }), 54 | ); 55 | const secondItems = ['key3', ''].map((Key) => ({ Key })); 56 | promise.mockReturnValueOnce( 57 | Promise.resolve({ Contents: secondItems, IsTruncated: false }), 58 | ); 59 | 60 | jest.clearAllMocks(); 61 | 62 | await clearAllObjects(region, bucket); 63 | 64 | expect(s3).toHaveBeenCalledTimes(3); 65 | expect(s3).toHaveBeenCalledWith({ region }); 66 | expect(listObjectsV2).toHaveBeenCalledTimes(2); 67 | expect(listObjectsV2).toHaveBeenCalledWith({ 68 | Bucket: bucket, 69 | ContinuationToken: undefined, 70 | }); 71 | expect(listObjectsV2).toHaveBeenCalledWith({ 72 | Bucket: bucket, 73 | ContinuationToken: nextContinuationToken, 74 | }); 75 | expect(deleteObjects).toHaveBeenCalledTimes(1); 76 | expect(deleteObjects).toHaveBeenCalledWith({ 77 | Bucket: bucket, 78 | Delete: { 79 | Objects: [...firstItems, ...secondItems], 80 | Quiet: false, 81 | }, 82 | }); 83 | }); 84 | 85 | test('should call listObjectsV2 with prefix', async () => { 86 | const listObjectsV2 = s3().listObjectsV2; 87 | const promise = listObjectsV2().promise; 88 | const items = ['key1', 'key2'].map((Key) => ({ Key })); 89 | promise.mockReturnValueOnce( 90 | Promise.resolve({ 91 | Contents: items, 92 | }), 93 | ); 94 | 95 | jest.clearAllMocks(); 96 | 97 | const prefix = 'prefix'; 98 | await clearAllObjects(region, bucket, prefix); 99 | 100 | expect(listObjectsV2).toHaveBeenCalledTimes(1); 101 | expect(listObjectsV2).toHaveBeenCalledWith({ 102 | Bucket: bucket, 103 | ContinuationToken: undefined, 104 | Prefix: prefix, 105 | }); 106 | }); 107 | }); 108 | 109 | describe('getObject', () => { 110 | test('should return buffer on existing object', async () => { 111 | const getObject = s3().getObject; 112 | const promise = getObject().promise; 113 | 114 | const expectedBuffer = Buffer.from('some data'); 115 | promise.mockReturnValue(Promise.resolve({ Body: expectedBuffer })); 116 | 117 | jest.clearAllMocks(); 118 | 119 | const key = 'key'; 120 | const { body: actualBuffer, found } = await getS3Object( 121 | region, 122 | bucket, 123 | key, 124 | ); 125 | 126 | expect(s3).toHaveBeenCalledTimes(1); 127 | expect(s3).toHaveBeenCalledWith({ region }); 128 | expect(getObject).toHaveBeenCalledTimes(1); 129 | expect(getObject).toHaveBeenCalledWith({ Bucket: bucket, Key: key }); 130 | 131 | expect(found).toBeTruthy(); 132 | expect(actualBuffer).toEqual(expectedBuffer); 133 | }); 134 | 135 | class ErrorWithCode extends Error { 136 | public code: string; 137 | 138 | constructor({ code, message }: { code: string; message: string }) { 139 | super(message); 140 | this.code = code; 141 | } 142 | } 143 | 144 | test('should return found === false on non existing item', async () => { 145 | const getObject = s3().getObject; 146 | const promise = getObject().promise; 147 | 148 | promise.mockReturnValue( 149 | Promise.reject(new ErrorWithCode({ code: 'NoSuchKey', message: '' })), 150 | ); 151 | 152 | jest.clearAllMocks(); 153 | 154 | const key = 'key'; 155 | const { body, found } = await getS3Object(region, bucket, key); 156 | 157 | expect(found).toBeFalsy(); 158 | expect(body).toEqual(null); 159 | }); 160 | 161 | test('should throw error on unknown error', async () => { 162 | const getObject = s3().getObject; 163 | const promise = getObject().promise; 164 | 165 | const error = new ErrorWithCode({ 166 | code: 'SomeUnknownError', 167 | message: '', 168 | }); 169 | promise.mockReturnValue(Promise.reject(error)); 170 | 171 | jest.clearAllMocks(); 172 | 173 | expect.assertions(1); 174 | const key = 'key'; 175 | await expect(getS3Object(region, bucket, key)).rejects.toBe(error); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/utils/s3.ts: -------------------------------------------------------------------------------- 1 | import AWS = require('aws-sdk'); 2 | 3 | const listAllKeys = async ( 4 | region: string, 5 | bucket: string, 6 | prefix: string | undefined, 7 | token: string | undefined, 8 | ) => { 9 | const s3 = new AWS.S3({ region }); 10 | const opts = { 11 | Bucket: bucket, 12 | ContinuationToken: token, 13 | ...(prefix && { Prefix: prefix }), 14 | }; 15 | const data = await s3.listObjectsV2(opts).promise(); 16 | let allKeys = data.Contents || []; 17 | if (data.IsTruncated) { 18 | allKeys = allKeys.concat( 19 | await listAllKeys(region, bucket, prefix, data.NextContinuationToken), 20 | ); 21 | } 22 | 23 | return allKeys; 24 | }; 25 | 26 | export const clearAllObjects = async ( 27 | region: string, 28 | bucket: string, 29 | prefix?: string, 30 | ) => { 31 | const allKeys = await listAllKeys(region, bucket, prefix, undefined); 32 | if (allKeys.length > 0) { 33 | const s3 = new AWS.S3({ region }); 34 | const objects = allKeys.map((item) => ({ Key: item.Key || '' })); 35 | await s3 36 | .deleteObjects({ 37 | Bucket: bucket, 38 | Delete: { 39 | Objects: objects, 40 | Quiet: false, 41 | }, 42 | }) 43 | .promise(); 44 | } 45 | }; 46 | 47 | export const getObject = async ( 48 | region: string, 49 | bucket: string, 50 | key: string, 51 | ) => { 52 | try { 53 | const s3 = new AWS.S3({ region }); 54 | // throws error if key not found 55 | const body = (await s3.getObject({ Bucket: bucket, Key: key }).promise()) 56 | .Body as Buffer; 57 | return { body, found: true }; 58 | } catch (error) { 59 | const e = error as AWS.AWSError; 60 | if (e.code === 'NoSuchKey') { 61 | return { body: null, found: false }; 62 | } else { 63 | throw e; 64 | } 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/utils/serverless.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { EOL } from 'os'; 3 | import { deploy } from './serverless'; 4 | 5 | jest.mock('child_process'); 6 | jest.spyOn(console, 'log'); 7 | jest.spyOn(console, 'error'); 8 | 9 | describe('serverless utils', () => { 10 | beforeEach(() => { 11 | jest.clearAllMocks(); 12 | }); 13 | 14 | const { spawn } = require('child_process'); 15 | const spawned = { 16 | on: jest.fn(), 17 | stderr: { on: jest.fn() }, 18 | stdout: { on: jest.fn() }, 19 | }; 20 | spawn.mockReturnValue(spawned); 21 | 22 | describe('deploy', () => { 23 | test('resolves on exit code === 0', async () => { 24 | const stage = 'stage'; 25 | const promise = deploy(stage); 26 | 27 | expect(spawn).toHaveBeenCalledTimes(1); 28 | expect(spawn).toHaveBeenCalledWith('serverless', [ 29 | 'deploy', 30 | '--stage', 31 | stage, 32 | ]); 33 | 34 | expect(spawned.stdout.on).toHaveBeenCalledTimes(1); 35 | expect(spawned.stdout.on).toHaveBeenCalledWith( 36 | 'data', 37 | expect.any(Function), 38 | ); 39 | const data = 'Serverless output'; 40 | spawned.stdout.on.mock.calls[0][1](data); 41 | 42 | expect(spawned.on).toHaveBeenCalledTimes(1); 43 | expect(spawned.on).toHaveBeenCalledWith('close', expect.any(Function)); 44 | const exitCode = 0; 45 | spawned.on.mock.calls[0][1](exitCode); 46 | 47 | await expect(promise).resolves.toEqual(exitCode); 48 | 49 | expect(console.log).toHaveBeenCalledTimes(2); 50 | expect(console.log).toHaveBeenCalledWith(`${EOL}Deploying Service`); 51 | expect(console.log).toHaveBeenCalledWith(data); 52 | 53 | expect.assertions(10); 54 | }); 55 | 56 | test('rejects on exit code !== 1', async () => { 57 | const promise = deploy(); 58 | 59 | expect(spawn).toHaveBeenCalledTimes(1); 60 | expect(spawn).toHaveBeenCalledWith('serverless', [ 61 | 'deploy', 62 | '--stage', 63 | 'dev', 64 | ]); 65 | 66 | expect(spawned.stderr.on).toHaveBeenCalledTimes(1); 67 | expect(spawned.stderr.on).toHaveBeenCalledWith( 68 | 'data', 69 | expect.any(Function), 70 | ); 71 | const data = 'Serverless error'; 72 | spawned.stderr.on.mock.calls[0][1](data); 73 | 74 | const exitCode = 1; 75 | spawned.on.mock.calls[0][1](exitCode); 76 | 77 | await expect(promise).rejects.toEqual(exitCode); 78 | expect(console.error).toHaveBeenCalledTimes(1); 79 | expect(console.error).toHaveBeenCalledWith(data); 80 | 81 | expect.assertions(7); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/utils/serverless.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import { EOL } from 'os'; 3 | 4 | export const deploy = async (stage = 'dev') => { 5 | return await new Promise((resolve, reject) => { 6 | const serverless = spawn('serverless', ['deploy', '--stage', stage]); 7 | 8 | console.log(`${EOL}Deploying Service`); 9 | serverless.stdout.on('data', (data) => { 10 | console.log(data.toString().trim()); 11 | }); 12 | 13 | serverless.stderr.on('data', (data) => { 14 | console.error(data.toString().trim()); 15 | }); 16 | 17 | serverless.on('close', (code) => { 18 | if (code !== 0) { 19 | reject(code); 20 | } else { 21 | resolve(code); 22 | } 23 | }); 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/sqs.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { existsInQueue, subscribeToTopic, unsubscribeFromTopic } from './sqs'; 3 | 4 | jest.mock('aws-sdk', () => { 5 | const subscribeValue = { promise: jest.fn() }; 6 | const subscribe = jest.fn(() => subscribeValue); 7 | 8 | const unsubscribeValue = { promise: jest.fn() }; 9 | const unsubscribe = jest.fn(() => unsubscribeValue); 10 | 11 | const SNS = jest.fn(() => ({ subscribe, unsubscribe })); 12 | 13 | const setQueueAttributesValue = { promise: jest.fn() }; 14 | const setQueueAttributes = jest.fn(() => setQueueAttributesValue); 15 | 16 | const deleteQueueValue = { promise: jest.fn() }; 17 | const deleteQueue = jest.fn(() => deleteQueueValue); 18 | 19 | const getQueueAttributesValue = { promise: jest.fn() }; 20 | const getQueueAttributes = jest.fn(() => getQueueAttributesValue); 21 | 22 | const createQueueValue = { promise: jest.fn() }; 23 | const createQueue = jest.fn(() => createQueueValue); 24 | 25 | const receiveMessageValue = { promise: jest.fn() }; 26 | const receiveMessage = jest.fn(() => receiveMessageValue); 27 | 28 | const SQS = jest.fn(() => ({ 29 | createQueue, 30 | deleteQueue, 31 | getQueueAttributes, 32 | receiveMessage, 33 | setQueueAttributes, 34 | })); 35 | return { SQS, SNS }; 36 | }); 37 | 38 | jest.mock('uuid', () => { 39 | const v1 = jest.fn(() => '00000000'); 40 | return { v1 }; 41 | }); 42 | 43 | describe('kinesis utils', () => { 44 | const AWS = require('aws-sdk'); 45 | const sqs = AWS.SQS; 46 | const sns = AWS.SNS; 47 | 48 | const region = 'region'; 49 | 50 | test('should return { subscriptionArn, queueUrl } on subscribeToTopic', async () => { 51 | const createQueue = sqs().createQueue; 52 | const createQueuePromise = createQueue().promise; 53 | 54 | const QueueUrl = 'QueueUrl'; 55 | createQueuePromise.mockReturnValue({ 56 | QueueUrl, 57 | }); 58 | 59 | const getQueueAttributes = sqs().getQueueAttributes; 60 | const getQueueAttributesPromise = getQueueAttributes().promise; 61 | 62 | const setQueueAttributes = sqs().setQueueAttributes; 63 | 64 | const QueueArn = 'QueueArn'; 65 | getQueueAttributesPromise.mockReturnValue({ 66 | Attributes: { QueueArn }, 67 | }); 68 | 69 | const subscribe = sns().subscribe; 70 | const subscribePromise = subscribe().promise; 71 | 72 | const SubscriptionArn = 'SubscriptionArn'; 73 | subscribePromise.mockReturnValue({ 74 | SubscriptionArn, 75 | }); 76 | 77 | jest.clearAllMocks(); 78 | 79 | const topicArn = 'topicArn'; 80 | 81 | const result = await subscribeToTopic(region, topicArn); 82 | 83 | expect.assertions(9); 84 | 85 | expect(createQueue).toHaveBeenCalledTimes(1); 86 | expect(createQueue).toHaveBeenCalledWith({ 87 | Attributes: { VisibilityTimeout: '0' }, 88 | QueueName: 'TestNotificationTopicQueue-00000000', 89 | }); 90 | 91 | expect(getQueueAttributes).toHaveBeenCalledTimes(1); 92 | expect(getQueueAttributes).toHaveBeenCalledWith({ 93 | AttributeNames: ['QueueArn'], 94 | QueueUrl, 95 | }); 96 | 97 | const policy = { 98 | Statement: [ 99 | { 100 | Action: 'sqs:SendMessage', 101 | Condition: { 102 | ArnEquals: { 103 | 'aws:SourceArn': topicArn, 104 | }, 105 | }, 106 | Effect: 'Allow', 107 | Principal: { 108 | AWS: '*', 109 | }, 110 | Resource: QueueArn, 111 | Sid: 'TestNotificationTopicQueuePolicy', 112 | }, 113 | ], 114 | }; 115 | 116 | expect(setQueueAttributes).toHaveBeenCalledTimes(1); 117 | expect(setQueueAttributes).toHaveBeenCalledWith({ 118 | Attributes: { Policy: JSON.stringify(policy) }, 119 | QueueUrl, 120 | }); 121 | 122 | expect(subscribe).toHaveBeenCalledTimes(1); 123 | expect(subscribe).toHaveBeenCalledWith({ 124 | Endpoint: QueueArn, 125 | Protocol: 'sqs', 126 | TopicArn: topicArn, 127 | }); 128 | 129 | expect(result).toEqual({ 130 | queueUrl: QueueUrl, 131 | subscriptionArn: SubscriptionArn, 132 | }); 133 | }); 134 | 135 | test('should unsubscribe from topic and delete queue on unsubscribeFromTopic', async () => { 136 | const unsubscribe = sns().unsubscribe; 137 | const unsubscribePromise = unsubscribe().promise; 138 | 139 | const deleteQueue = sqs().deleteQueue; 140 | const deleteQueuePromise = deleteQueue().promise; 141 | 142 | jest.clearAllMocks(); 143 | 144 | const subscriptionArn = 'subscriptionArn'; 145 | const queueUrl = 'queueUrl'; 146 | 147 | await unsubscribeFromTopic(region, subscriptionArn, queueUrl); 148 | 149 | expect.assertions(6); 150 | 151 | expect(unsubscribe).toHaveBeenCalledTimes(1); 152 | expect(unsubscribePromise).toHaveBeenCalledTimes(1); 153 | expect(unsubscribe).toHaveBeenCalledWith({ 154 | SubscriptionArn: subscriptionArn, 155 | }); 156 | 157 | expect(deleteQueue).toHaveBeenCalledTimes(1); 158 | expect(deleteQueuePromise).toHaveBeenCalledTimes(1); 159 | expect(deleteQueue).toHaveBeenCalledWith({ QueueUrl: queueUrl }); 160 | }); 161 | 162 | test('should return true on existsInQueue when message is found', async () => { 163 | const queueUrl = 'queueUrl'; 164 | const matcher = jest.fn().mockImplementation(() => true); 165 | 166 | const receiveMessage = sqs().receiveMessage; 167 | const receiveMessageValue = receiveMessage().promise; 168 | const Messages = [ 169 | { Body: JSON.stringify({ Subject: 'Subject', Message: 'Message' }) }, 170 | ]; 171 | receiveMessageValue.mockReturnValue({ 172 | Messages, 173 | }); 174 | 175 | jest.clearAllMocks(); 176 | 177 | const result = await existsInQueue(region, queueUrl, matcher); 178 | 179 | expect.assertions(5); 180 | 181 | expect(receiveMessage).toHaveBeenCalledTimes(1); 182 | expect(receiveMessage).toHaveBeenCalledWith({ 183 | QueueUrl: queueUrl, 184 | WaitTimeSeconds: 20, 185 | }); 186 | 187 | expect(matcher).toHaveBeenCalledTimes(Messages.length); 188 | expect(matcher).toHaveBeenCalledWith(JSON.parse(Messages[0].Body), 0, [ 189 | JSON.parse(Messages[0].Body), 190 | ]); 191 | 192 | expect(result).toBe(true); 193 | }); 194 | 195 | test('should return false on existsInQueue when no messages', async () => { 196 | const queueUrl = 'queueUrl'; 197 | const matcher = jest.fn(() => true); 198 | 199 | const receiveMessage = sqs().receiveMessage; 200 | const receiveMessageValue = receiveMessage().promise; 201 | receiveMessageValue.mockReturnValue({}); 202 | 203 | jest.clearAllMocks(); 204 | 205 | const result = await existsInQueue(region, queueUrl, matcher); 206 | 207 | expect.assertions(4); 208 | 209 | expect(receiveMessage).toHaveBeenCalledTimes(1); 210 | expect(receiveMessage).toHaveBeenCalledWith({ 211 | QueueUrl: queueUrl, 212 | WaitTimeSeconds: 20, 213 | }); 214 | 215 | expect(matcher).toHaveBeenCalledTimes(0); 216 | expect(result).toBe(false); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /src/utils/sqs.ts: -------------------------------------------------------------------------------- 1 | import AWS = require('aws-sdk'); 2 | import { v1 as uuid } from 'uuid'; 3 | 4 | export type IMessageMatcher = (args: any) => boolean; 5 | 6 | export const subscribeToTopic = async (region: string, topicArn: string) => { 7 | const sqs = new AWS.SQS({ region }); 8 | const queueName = `TestNotificationTopicQueue-${uuid()}`; 9 | const { QueueUrl } = (await sqs 10 | .createQueue({ 11 | Attributes: { VisibilityTimeout: '0' }, 12 | QueueName: queueName, 13 | }) 14 | .promise()) as { QueueUrl: string }; 15 | 16 | const { Attributes } = (await sqs 17 | .getQueueAttributes({ 18 | AttributeNames: ['QueueArn'], 19 | QueueUrl, 20 | }) 21 | .promise()) as { Attributes: AWS.SQS.QueueAttributeMap }; 22 | 23 | const { QueueArn } = Attributes; 24 | 25 | const policy = { 26 | Statement: [ 27 | { 28 | Action: 'sqs:SendMessage', 29 | Condition: { 30 | ArnEquals: { 31 | 'aws:SourceArn': topicArn, 32 | }, 33 | }, 34 | Effect: 'Allow', 35 | Principal: { 36 | AWS: '*', 37 | }, 38 | Resource: QueueArn, 39 | Sid: 'TestNotificationTopicQueuePolicy', 40 | }, 41 | ], 42 | }; 43 | 44 | await sqs 45 | .setQueueAttributes({ 46 | Attributes: { Policy: JSON.stringify(policy) }, 47 | QueueUrl, 48 | }) 49 | .promise(); 50 | 51 | const sns = new AWS.SNS({ region }); 52 | const { SubscriptionArn } = await sns 53 | .subscribe({ TopicArn: topicArn, Protocol: 'sqs', Endpoint: QueueArn }) 54 | .promise(); 55 | 56 | return { subscriptionArn: SubscriptionArn as string, queueUrl: QueueUrl }; 57 | }; 58 | 59 | export const unsubscribeFromTopic = async ( 60 | region: string, 61 | subscriptionArn: string, 62 | queueUrl: string, 63 | ) => { 64 | const sqs = new AWS.SQS({ region }); 65 | const sns = new AWS.SNS({ region }); 66 | 67 | await sns.unsubscribe({ SubscriptionArn: subscriptionArn }).promise(); 68 | await sqs.deleteQueue({ QueueUrl: queueUrl }).promise(); 69 | }; 70 | 71 | export const existsInQueue = async ( 72 | region: string, 73 | queueUrl: string, 74 | matcher: IMessageMatcher, 75 | ) => { 76 | const sqs = new AWS.SQS({ region }); 77 | const { Messages = [] } = await sqs 78 | .receiveMessage({ QueueUrl: queueUrl, WaitTimeSeconds: 20 }) 79 | .promise(); 80 | 81 | const messages = Messages.map((item) => JSON.parse(item.Body as string)); 82 | 83 | const exists = messages.some(matcher); 84 | return exists; 85 | }; 86 | -------------------------------------------------------------------------------- /src/utils/stepFunctions.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { 3 | getCurrentState, 4 | getEventName, 5 | getStates, 6 | stopRunningExecutions, 7 | } from './stepFunctions'; 8 | 9 | jest.mock('aws-sdk', () => { 10 | const stopExecutionValue = { promise: jest.fn() }; 11 | const stopExecution = jest.fn(() => stopExecutionValue); 12 | const getExecutionHistoryValue = { promise: jest.fn() }; 13 | const getExecutionHistory = jest.fn(() => getExecutionHistoryValue); 14 | const listExecutionsValue = { promise: jest.fn() }; 15 | const listExecutions = jest.fn(() => listExecutionsValue); 16 | const StepFunctions = jest.fn(() => ({ 17 | getExecutionHistory, 18 | listExecutions, 19 | stopExecution, 20 | })); 21 | return { StepFunctions }; 22 | }); 23 | 24 | describe('stepfunctions utils', () => { 25 | const AWS = require('aws-sdk'); 26 | const stepFunctions = AWS.StepFunctions; 27 | 28 | const [region, stateMachineArn] = ['region', 'stateMachineArn']; 29 | 30 | describe('stopRunningExecutions', () => { 31 | test('should stop running executions', async () => { 32 | const listExecutions = stepFunctions().listExecutions; 33 | const stopExecution = stepFunctions().stopExecution; 34 | const promise = listExecutions().promise; 35 | const executions = [ 36 | { executionArn: 'executionArn1' }, 37 | { executionArn: 'executionArn2' }, 38 | ]; 39 | promise.mockReturnValue(Promise.resolve({ executions })); 40 | 41 | jest.clearAllMocks(); 42 | 43 | await stopRunningExecutions(region, stateMachineArn); 44 | 45 | expect(listExecutions).toHaveBeenCalledTimes(1); 46 | expect(listExecutions).toHaveBeenCalledWith({ 47 | maxResults: 1, 48 | stateMachineArn, 49 | statusFilter: 'RUNNING', 50 | }); 51 | expect(stopExecution).toHaveBeenCalledTimes(executions.length); 52 | for (const execution of executions) { 53 | const { executionArn } = execution; 54 | expect(stopExecution).toHaveBeenCalledWith({ 55 | executionArn, 56 | }); 57 | } 58 | }); 59 | }); 60 | 61 | describe('getEventName', () => { 62 | const event = { 63 | id: 0, 64 | timestamp: new Date(), 65 | type: 'someEventType', 66 | }; 67 | test('should return stateEnteredEvent name', () => { 68 | const stateEnteredEvent = { 69 | ...event, 70 | stateEnteredEventDetails: { name: 'someEventName' }, 71 | }; 72 | expect(getEventName(stateEnteredEvent)).toBe( 73 | stateEnteredEvent.stateEnteredEventDetails.name, 74 | ); 75 | }); 76 | 77 | test('should return stateExitedEventDetails name', () => { 78 | const stateExitedEvent = { 79 | ...event, 80 | stateExitedEventDetails: { name: 'someEventName' }, 81 | }; 82 | expect(getEventName(stateExitedEvent)).toBe( 83 | stateExitedEvent.stateExitedEventDetails.name, 84 | ); 85 | }); 86 | 87 | test('should return undefined', () => { 88 | const unnamedEvent = { 89 | ...event, 90 | }; 91 | expect(getEventName(unnamedEvent)).toBeUndefined(); 92 | }); 93 | }); 94 | 95 | describe('getCurrentState', () => { 96 | test('should return undefined on no executions', async () => { 97 | const listExecutions = stepFunctions().listExecutions; 98 | const getExecutionHistory = stepFunctions().getExecutionHistory; 99 | const promise = listExecutions().promise; 100 | const executions: AWS.StepFunctions.ExecutionListItem[] = []; 101 | promise.mockReturnValue(Promise.resolve({ executions })); 102 | 103 | jest.clearAllMocks(); 104 | 105 | const result = await getCurrentState(region, stateMachineArn); 106 | 107 | expect(getExecutionHistory).toHaveBeenCalledTimes(0); 108 | expect(listExecutions).toHaveBeenCalledTimes(1); 109 | expect(listExecutions).toHaveBeenCalledWith({ 110 | maxResults: 1, 111 | stateMachineArn, 112 | statusFilter: 'RUNNING', 113 | }); 114 | expect(result).toBeUndefined(); 115 | }); 116 | 117 | test('should return undefined on no history events', async () => { 118 | const listExecutions = stepFunctions().listExecutions; 119 | const getExecutionHistory = stepFunctions().getExecutionHistory; 120 | 121 | const listExecutionsPromise = listExecutions().promise; 122 | const executions = [ 123 | { executionArn: 'executionArn1' }, 124 | { executionArn: 'executionArn2' }, 125 | ]; 126 | listExecutionsPromise.mockReturnValue(Promise.resolve({ executions })); 127 | 128 | const getExecutionHistoryPromise = getExecutionHistory().promise; 129 | const events: AWS.StepFunctions.HistoryEvent[] = []; 130 | getExecutionHistoryPromise.mockReturnValue(Promise.resolve({ events })); 131 | 132 | jest.clearAllMocks(); 133 | 134 | const result = await getCurrentState(region, stateMachineArn); 135 | 136 | expect(getExecutionHistory).toHaveBeenCalledTimes(1); 137 | expect(getExecutionHistory).toHaveBeenCalledWith({ 138 | executionArn: executions[0].executionArn, 139 | maxResults: 1, 140 | reverseOrder: true, 141 | }); 142 | expect(result).toBeUndefined(); 143 | }); 144 | 145 | test('should return current state', async () => { 146 | const listExecutions = stepFunctions().listExecutions; 147 | const getExecutionHistory = stepFunctions().getExecutionHistory; 148 | 149 | const listExecutionsPromise = listExecutions().promise; 150 | const executions = [ 151 | { executionArn: 'executionArn1' }, 152 | { executionArn: 'executionArn2' }, 153 | ]; 154 | listExecutionsPromise.mockReturnValue(Promise.resolve({ executions })); 155 | 156 | const getExecutionHistoryPromise = getExecutionHistory().promise; 157 | const events = [ 158 | { stateEnteredEventDetails: { name: 'someEventName' } }, 159 | { stateEnteredEventDetails: { name: 'otherEventName' } }, 160 | ]; 161 | getExecutionHistoryPromise.mockReturnValue(Promise.resolve({ events })); 162 | 163 | jest.clearAllMocks(); 164 | 165 | const result = await getCurrentState(region, stateMachineArn); 166 | 167 | expect(result).toBe(events[0].stateEnteredEventDetails.name); 168 | }); 169 | }); 170 | 171 | describe('getStates', () => { 172 | test('should return empty array on no executions', async () => { 173 | const listExecutions = stepFunctions().listExecutions; 174 | const getExecutionHistory = stepFunctions().getExecutionHistory; 175 | const promise = listExecutions().promise; 176 | const executions: AWS.StepFunctions.ExecutionListItem[] = []; 177 | promise.mockReturnValue(Promise.resolve({ executions })); 178 | 179 | jest.clearAllMocks(); 180 | 181 | const result = await getStates(region, stateMachineArn); 182 | 183 | expect(getExecutionHistory).toHaveBeenCalledTimes(0); 184 | expect(listExecutions).toHaveBeenCalledTimes(1); 185 | expect(listExecutions).toHaveBeenCalledWith({ 186 | maxResults: 1, 187 | stateMachineArn, 188 | }); 189 | expect(result).toEqual([]); 190 | }); 191 | 192 | test('should return state names', async () => { 193 | const listExecutions = stepFunctions().listExecutions; 194 | const getExecutionHistory = stepFunctions().getExecutionHistory; 195 | 196 | const listExecutionsPromise = listExecutions().promise; 197 | const executions = [ 198 | { executionArn: 'executionArn1' }, 199 | { executionArn: 'executionArn2' }, 200 | ]; 201 | listExecutionsPromise.mockReturnValue(Promise.resolve({ executions })); 202 | 203 | const getExecutionHistoryPromise = getExecutionHistory().promise; 204 | const events = [ 205 | { stateEnteredEventDetails: { name: 'someEventName' } }, 206 | { stateEnteredEventDetails: { name: 'otherEventName' } }, 207 | {}, 208 | ]; 209 | getExecutionHistoryPromise.mockReturnValue(Promise.resolve({ events })); 210 | 211 | jest.clearAllMocks(); 212 | 213 | const result = await getStates(region, stateMachineArn); 214 | 215 | expect(getExecutionHistory).toHaveBeenCalledTimes(1); 216 | expect(getExecutionHistory).toHaveBeenCalledWith({ 217 | executionArn: executions[0].executionArn, 218 | reverseOrder: true, 219 | }); 220 | expect(result).toEqual( 221 | events 222 | .map( 223 | (e) => 224 | e.stateEnteredEventDetails && e.stateEnteredEventDetails.name, 225 | ) 226 | .filter((name) => !!name), 227 | ); 228 | }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /src/utils/stepFunctions.ts: -------------------------------------------------------------------------------- 1 | import AWS = require('aws-sdk'); 2 | 3 | const getExecutions = async ( 4 | region: string, 5 | stateMachineArn: string, 6 | statusFilter?: string, 7 | ) => { 8 | const stepFunctions = new AWS.StepFunctions({ region }); 9 | const opts = { 10 | maxResults: 1, 11 | stateMachineArn, 12 | ...(statusFilter && { statusFilter }), 13 | }; 14 | const result = await stepFunctions.listExecutions(opts).promise(); 15 | 16 | const { executions } = result; 17 | 18 | return executions; 19 | }; 20 | 21 | const RUNNING = 'RUNNING'; 22 | 23 | export const getEventName = (event: AWS.StepFunctions.HistoryEvent) => { 24 | const { name } = event.stateEnteredEventDetails || 25 | event.stateExitedEventDetails || { 26 | name: undefined, 27 | }; 28 | return name; 29 | }; 30 | 31 | export const getCurrentState = async ( 32 | region: string, 33 | stateMachineArn: string, 34 | ) => { 35 | const executions = await getExecutions(region, stateMachineArn, RUNNING); 36 | if (executions.length > 0) { 37 | const newestRunning = executions[0]; // the first is the newest one 38 | 39 | const stepFunctions = new AWS.StepFunctions({ region }); 40 | const { executionArn } = newestRunning; 41 | const { events } = await stepFunctions 42 | .getExecutionHistory({ executionArn, reverseOrder: true, maxResults: 1 }) 43 | .promise(); 44 | if (events.length > 0) { 45 | const newestEvent = events[0]; 46 | const name = getEventName(newestEvent); 47 | return name; 48 | } else { 49 | return undefined; 50 | } 51 | } 52 | return undefined; 53 | }; 54 | 55 | export const getStates = async (region: string, stateMachineArn: string) => { 56 | const executions = await getExecutions(region, stateMachineArn); 57 | if (executions.length > 0) { 58 | const newestRunning = executions[0]; // the first is the newest one 59 | 60 | const stepFunctions = new AWS.StepFunctions({ region }); 61 | const { executionArn } = newestRunning; 62 | const { events } = await stepFunctions 63 | .getExecutionHistory({ executionArn, reverseOrder: true }) 64 | .promise(); 65 | const names = events 66 | .map((event) => getEventName(event)) 67 | .filter((name) => !!name); 68 | return names; 69 | } 70 | return []; 71 | }; 72 | 73 | export const stopRunningExecutions = async ( 74 | region: string, 75 | stateMachineArn: string, 76 | ) => { 77 | const stepFunctions = new AWS.StepFunctions({ region }); 78 | const executions = await getExecutions(region, stateMachineArn, RUNNING); 79 | 80 | await Promise.all( 81 | executions.map(({ executionArn }) => 82 | stepFunctions.stopExecution({ executionArn }).promise(), 83 | ), 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": true, 5 | "noImplicitReturns": true, 6 | "strict": true, 7 | "noUnusedLocals": true, 8 | "declaration": true, 9 | "outDir": "./lib", 10 | "allowJs": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "**/*.test.ts"] 14 | } 15 | --------------------------------------------------------------------------------