├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── test.yaml ├── .gitignore ├── .npmignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── scripts ├── publish-container │ ├── Dockerfile │ └── cloudbuild.yaml ├── publish.sh ├── publish │ ├── cloudbuild.yaml │ ├── deploy_key.enc │ ├── hub.enc │ ├── npmrc.enc │ └── twitter.json.enc └── tweet.js ├── spec ├── app.spec.ts ├── cloudevent │ └── generate.ts ├── index.spec.ts ├── integration │ └── providers │ │ └── firestore.spec.ts ├── lifecycle.spec.ts ├── main.spec.ts ├── providers │ ├── database.spec.ts │ ├── firestore.spec.ts │ ├── https.spec.ts │ └── scheduled.spec.ts ├── secretmanager.spec.ts └── v2.spec.ts ├── src ├── app.ts ├── cloudevent │ ├── generate.ts │ ├── mocks │ │ ├── alerts │ │ │ ├── alerts-on-alert-published.ts │ │ │ ├── app-distribution-on-new-tester-ios-device-published.ts │ │ │ ├── billing-on-plan-automated-update-published.ts │ │ │ ├── billing-on-plan-update-published.ts │ │ │ ├── crashlytics-on-new-anr-issue-published.ts │ │ │ ├── crashlytics-on-new-fatal-issue-published.ts │ │ │ ├── crashlytics-on-new-nonfatal-issue-published.ts │ │ │ ├── crashlytics-on-regression-alert-published.ts │ │ │ ├── crashlytics-on-stability-digest-published.ts │ │ │ ├── crashlytics-on-velocity-alert-published.ts │ │ │ └── performance-on-threshold-alert-published.ts │ │ ├── database │ │ │ ├── database-on-value-created.ts │ │ │ ├── database-on-value-deleted.ts │ │ │ ├── database-on-value-updated.ts │ │ │ ├── database-on-value-written.ts │ │ │ ├── helpers.ts │ │ │ └── index.ts │ │ ├── eventarc │ │ │ └── eventarc-on-custom-event-published.ts │ │ ├── firestore │ │ │ ├── firestore-on-document-created-with-auth-context.ts │ │ │ ├── firestore-on-document-created.ts │ │ │ ├── firestore-on-document-deleted-with-auth-context.ts │ │ │ ├── firestore-on-document-deleted.ts │ │ │ ├── firestore-on-document-updated-with-auth-context.ts │ │ │ ├── firestore-on-document-updated.ts │ │ │ ├── firestore-on-document-written-with-auth-context.ts │ │ │ ├── firestore-on-document-written.ts │ │ │ ├── helpers.ts │ │ │ └── index.ts │ │ ├── helpers.ts │ │ ├── partials.ts │ │ ├── pubsub │ │ │ └── pubsub-on-message-published.ts │ │ ├── remoteconfig │ │ │ └── remote-config-on-config-updated.ts │ │ ├── storage │ │ │ ├── index.ts │ │ │ └── storage-data.ts │ │ └── testlab │ │ │ └── test-lab-on-test-matrix-completed.ts │ └── types.ts ├── features.ts ├── index.ts ├── lifecycle.ts ├── main.ts ├── providers │ ├── analytics.ts │ ├── auth.ts │ ├── database.ts │ ├── firestore.ts │ ├── pubsub.ts │ └── storage.ts ├── secretManager.ts ├── v1.ts └── v2.ts ├── tsconfig.json ├── tsconfig.release.json └── tslint.json /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult [GitHub Help] for more 22 | information on using pull requests. 23 | 24 | [GitHub Help]: https://help.github.com/articles/about-pull-requests/ 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 31 | 32 | ### Version info 33 | 34 | 36 | 37 | **firebase-functions-test:** 38 | 39 | **firebase-functions:** 40 | 41 | **firebase-admin:** 42 | 43 | ### Test case 44 | 45 | 46 | 47 | 48 | ### Steps to reproduce 49 | 50 | 51 | 52 | 53 | ### Expected behavior 54 | 55 | 56 | 57 | 58 | ### Actual behavior 59 | 60 | 61 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 22 | 23 | ### Description 24 | 25 | 27 | 28 | ### Code sample 29 | 30 | 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: CI Tests 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | env: 8 | CI: true 9 | 10 | jobs: 11 | unit: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: 16 | - 20.x 17 | - 22.x 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: Cache npm 25 | uses: actions/cache@v4 26 | with: 27 | path: ~/.npm 28 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 29 | 30 | - run: npm ci 31 | - run: npm run lint 32 | - run: npm run format 33 | - run: npm run test 34 | integration: 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | node-version: 39 | - 20.x 40 | - 22.x 41 | steps: 42 | - uses: actions/checkout@v3 43 | - uses: actions/setup-node@v3 44 | with: 45 | node-version: ${{ matrix.node-version }} 46 | 47 | - name: Cache npm 48 | uses: actions/cache@v4 49 | with: 50 | path: ~/.npm 51 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 52 | 53 | - run: npm install 54 | - run: npm run integrationTest 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | lib 4 | .npmrc 5 | .vscode 6 | *.tgz 7 | .tmp 8 | *.log 9 | .idea 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .tmp 2 | coverage 3 | .vscode 4 | .idea 5 | tsconfig.* 6 | tslint.* 7 | .travis.yml 8 | .github 9 | 10 | # Don't include the raw typescript 11 | src 12 | spec 13 | *.tgz 14 | scripts 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/firebase-functions-test/fb6cd5ce738231306f9184430f051286f6869dba/CHANGELOG.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Firebase 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firebase Test SDK for Cloud Functions 2 | 3 | The `firebase-functions-test` is unit testing library for Cloud Functions for Firebase. It is a companion to [firebase-functions](https://github.com/Firebase/firebase-functions). 4 | 5 | _NOTE: This library can only be used with `firebase-functions` v3.20.1 or above._ 6 | 7 | ## Usage 8 | 9 | 1. Write some Firebase Functions 10 | 2. With your testing framework of choice, write a unit-test that imports your Firebase Functions. 11 | 3. `wrap` your Firebase Functions. You can invoke the Firebase Function's handler by invoking the `wrap` call. 12 | 13 | Eg. 14 | 15 | ```typescript 16 | import {myFirebaseFunction} from "../index"; // Your Firebase Functions 17 | import firebaseFunctionsTest from "firebase-functions-test"; 18 | 19 | // Extracting `wrap` out of the lazy-loaded features 20 | const {wrap} = firebaseFunctionsTest(); 21 | 22 | // `jest-ts` example 23 | test('my unit test', () => { 24 | const wrappedFirebaseFunction = wrap(myFirebaseFunction); 25 | 26 | // Invoke the firebase function 27 | wrappedFirebaseFunction(); 28 | 29 | // Invoke the firebase function with CloudEvent overrides 30 | wrappedFirebaseFunction({data: {arbitrary: 'values'}}); 31 | }); 32 | ``` 33 | 34 | ## Examples 35 | 36 | * [Unit Testing Gen-1 Cloud Functions using Mocha](https://github.com/firebase/functions-samples/tree/main/Node-1st-gen/quickstarts/uppercase-rtdb/functions) 37 | * [Unit Testing Gen-2 Cloud Functions using Mocha](https://github.com/firebase/functions-samples/tree/main/Node/test-functions-mocha/functions) 38 | * [Unit Testing Gen-2 Cloud Functions using Jest](https://github.com/firebase/functions-samples/tree/main/Node/test-functions-jest/functions) 39 | * [Unit Testing Gen-2 Cloud Functions using Jest-Ts](https://github.com/firebase/functions-samples/tree/main/Node/test-functions-jest-ts/functions) 40 | 41 | ## Learn more 42 | 43 | Learn more about unit testing Cloud Functions [here](https://firebase.google.com/docs/functions/unit-testing). 44 | 45 | ## Contributing 46 | 47 | To contribute a change, [check out the contributing guide](.github/CONTRIBUTING.md). 48 | 49 | ## License 50 | 51 | © Google, 2018. Licensed under [The MIT License](LICENSE). 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firebase-functions-test", 3 | "version": "3.4.1", 4 | "description": "A testing companion to firebase-functions.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "npm i && node_modules/.bin/tsc -p tsconfig.release.json", 8 | "build:pack": "npm prune --production && rm -rf lib && npm install && node_modules/.bin/tsc -p tsconfig.release.json && npm pack && npm install", 9 | "build:release": "npm ci --production && npm install --no-save typescript firebase-functions firebase-admin && node_modules/.bin/tsc -p tsconfig.release.json", 10 | "lint": "node_modules/.bin/tslint src/{**/*,*}.ts spec/{**/*,*}.ts", 11 | "pretest": "node_modules/.bin/tsc", 12 | "test": "mocha .tmp/spec/index.spec.js", 13 | "posttest": "npm run lint && rm -rf .tmp", 14 | "preintegrationTest": "node_modules/.bin/tsc", 15 | "integrationTest": "firebase emulators:exec --project=not-a-project --only firestore 'mocha .tmp/spec/integration/**/*.spec.js'", 16 | "postintegrationTest": "rm -rf .tmp", 17 | "format": "prettier --check '**/*.{json,ts,yml,yaml}'", 18 | "format:fix": "prettier --write '**/*.{json,ts,yml,yaml}'" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/firebase/firebase-functions-test.git" 23 | }, 24 | "keywords": [ 25 | "firebase", 26 | "functions", 27 | "google", 28 | "cloud", 29 | "test" 30 | ], 31 | "author": "Firebase Team", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/firebase/firebase-functions-test/issues" 35 | }, 36 | "publishConfig": { 37 | "registry": "https://wombat-dressing-room.appspot.com" 38 | }, 39 | "homepage": "https://github.com/firebase/firebase-functions-test#readme", 40 | "dependencies": { 41 | "@types/lodash": "^4.14.104", 42 | "lodash": "^4.17.5", 43 | "ts-deepmerge": "^2.0.1" 44 | }, 45 | "devDependencies": { 46 | "@types/chai": "~4.2.4", 47 | "@types/express": "4.17.8", 48 | "@types/mocha": "^5.2.7", 49 | "chai": "^4.2.0", 50 | "firebase-admin": "^12.0.0", 51 | "firebase-functions": "^4.9.0", 52 | "firebase-tools": "^8.9.2", 53 | "mocha": "^6.2.2", 54 | "prettier": "^1.19.1", 55 | "sinon": "^7.5.0", 56 | "tslint": "^5.20.0", 57 | "typescript": "^4.2.5" 58 | }, 59 | "peerDependencies": { 60 | "firebase-admin": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0", 61 | "firebase-functions": ">=4.9.0", 62 | "jest": ">=28.0.0" 63 | }, 64 | "engines": { 65 | "node": ">=14.0.0" 66 | }, 67 | "typings": "lib/index.d.ts" 68 | } 69 | -------------------------------------------------------------------------------- /scripts/publish-container/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | # Install dependencies 4 | RUN apt-get update && \ 5 | apt-get install -y curl git jq 6 | 7 | # Install npm at latest. 8 | RUN npm install --global npm@latest 9 | 10 | # Install hub 11 | RUN curl -fsSL --output hub.tgz https://github.com/github/hub/releases/download/v2.13.0/hub-linux-amd64-2.13.0.tgz 12 | RUN tar --strip-components=2 -C /usr/bin -xf hub.tgz hub-linux-amd64-2.13.0/bin/hub 13 | 14 | -------------------------------------------------------------------------------- /scripts/publish-container/cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/docker' 3 | args: ['build', '-t', 'gcr.io/$PROJECT_ID/fft-package-builder', '.'] 4 | images: ['gcr.io/$PROJECT_ID/fft-package-builder'] 5 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | printusage() { 5 | echo "publish.sh " 6 | echo "REPOSITORY_ORG and REPOSITORY_NAME should be set in the environment." 7 | echo "e.g. REPOSITORY_ORG=user, REPOSITORY_NAME=repo" 8 | echo "" 9 | echo "Arguments:" 10 | echo " version: 'patch', 'minor', or 'major'." 11 | } 12 | 13 | VERSION=$1 14 | if [[ $VERSION == "" ]]; then 15 | printusage 16 | exit 1 17 | elif [[ ! ($VERSION == "patch" || $VERSION == "minor" || $VERSION == "major") ]]; then 18 | printusage 19 | exit 1 20 | fi 21 | 22 | if [[ $REPOSITORY_ORG == "" ]]; then 23 | printusage 24 | exit 1 25 | fi 26 | if [[ $REPOSITORY_NAME == "" ]]; then 27 | printusage 28 | exit 1 29 | fi 30 | 31 | WDIR=$(pwd) 32 | 33 | echo "Checking for commands..." 34 | trap "echo 'Missing hub.'; exit 1" ERR 35 | which hub &> /dev/null 36 | trap - ERR 37 | 38 | trap "echo 'Missing node.'; exit 1" ERR 39 | which node &> /dev/null 40 | trap - ERR 41 | 42 | trap "echo 'Missing jq.'; exit 1" ERR 43 | which jq &> /dev/null 44 | trap - ERR 45 | echo "Checked for commands." 46 | 47 | echo "Checking for Twitter credentials..." 48 | trap "echo 'Missing Twitter credentials.'; exit 1" ERR 49 | test -f "${WDIR}/scripts/twitter.json" 50 | trap - ERR 51 | echo "Checked for Twitter credentials..." 52 | 53 | echo "Checking for logged-in npm user..." 54 | trap "echo 'Please login to npm using \`npm login --registry https://wombat-dressing-room.appspot.com\`'; exit 1" ERR 55 | npm whoami --registry https://wombat-dressing-room.appspot.com 56 | trap - ERR 57 | echo "Checked for logged-in npm user." 58 | 59 | echo "Moving to temporary directory.." 60 | TEMPDIR=$(mktemp -d) 61 | echo "[DEBUG] ${TEMPDIR}" 62 | cd "${TEMPDIR}" 63 | echo "Moved to temporary directory." 64 | 65 | echo "Cloning repository..." 66 | git clone "git@github.com:${REPOSITORY_ORG}/${REPOSITORY_NAME}.git" 67 | cd "${REPOSITORY_NAME}" 68 | echo "Cloned repository." 69 | 70 | echo "Making sure there is a changelog..." 71 | if [ ! -s CHANGELOG.md ]; then 72 | echo "CHANGELOG.md is empty. aborting." 73 | exit 1 74 | fi 75 | echo "Made sure there is a changelog." 76 | 77 | echo "Running npm ci..." 78 | npm ci 79 | echo "Ran npm ci." 80 | 81 | echo "Running tests..." 82 | npm test 83 | echo "Ran tests." 84 | 85 | echo "Running publish build..." 86 | npm run build:release 87 | echo "Ran publish build." 88 | 89 | echo "Making a $VERSION version..." 90 | npm version $VERSION 91 | NEW_VERSION=$(jq -r ".version" package.json) 92 | echo "Made a $VERSION version." 93 | 94 | echo "Making the release notes..." 95 | RELEASE_NOTES_FILE=$(mktemp) 96 | echo "[DEBUG] ${RELEASE_NOTES_FILE}" 97 | echo "v${NEW_VERSION}" >> "${RELEASE_NOTES_FILE}" 98 | echo "" >> "${RELEASE_NOTES_FILE}" 99 | cat CHANGELOG.md >> "${RELEASE_NOTES_FILE}" 100 | echo "Made the release notes." 101 | 102 | echo "Publishing to npm..." 103 | if [[ $DRY_RUN == "" ]]; then 104 | npm publish 105 | else 106 | echo "DRY RUN: running publish with --dry-run" 107 | npm publish --dry-run 108 | fi 109 | echo "Published to npm." 110 | 111 | if [[ $DRY_RUN != "" ]]; then 112 | echo "All other commands are mutations, and we are doing a dry run." 113 | echo "Terminating." 114 | exit 115 | fi 116 | 117 | echo "Cleaning up release notes..." 118 | rm CHANGELOG.md 119 | touch CHANGELOG.md 120 | git commit -m "[firebase-release] Removed change log and reset repo after ${NEW_VERSION} release" CHANGELOG.md 121 | echo "Cleaned up release notes." 122 | 123 | echo "Pushing to GitHub..." 124 | git push origin master --tags 125 | echo "Pushed to GitHub." 126 | 127 | echo "Publishing release notes..." 128 | hub release create --file "${RELEASE_NOTES_FILE}" "v${NEW_VERSION}" 129 | echo "Published release notes." 130 | 131 | echo "Making the tweet..." 132 | npm install --no-save twitter@1.7.1 133 | cp -v "${WDIR}/scripts/twitter.json" "${TEMPDIR}/${REPOSITORY_NAME}/scripts/" 134 | node ./scripts/tweet.js ${NEW_VERSION} 135 | echo "Made the tweet." 136 | 137 | -------------------------------------------------------------------------------- /scripts/publish/cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | # Decrypt the SSH key. 3 | - name: 'gcr.io/cloud-builders/gcloud' 4 | args: 5 | [ 6 | 'kms', 7 | 'decrypt', 8 | '--ciphertext-file=deploy_key.enc', 9 | '--plaintext-file=/root/.ssh/id_rsa', 10 | '--location=global', 11 | '--keyring=${_KEY_RING}', 12 | '--key=${_KEY_NAME}', 13 | ] 14 | 15 | # Decrypt the Twitter credentials. 16 | - name: 'gcr.io/cloud-builders/gcloud' 17 | args: 18 | [ 19 | 'kms', 20 | 'decrypt', 21 | '--ciphertext-file=twitter.json.enc', 22 | '--plaintext-file=twitter.json', 23 | '--location=global', 24 | '--keyring=${_KEY_RING}', 25 | '--key=${_KEY_NAME}', 26 | ] 27 | 28 | # Decrypt the npm credentials. 29 | - name: 'gcr.io/cloud-builders/gcloud' 30 | args: 31 | [ 32 | 'kms', 33 | 'decrypt', 34 | '--ciphertext-file=npmrc.enc', 35 | '--plaintext-file=npmrc', 36 | '--location=global', 37 | '--keyring=${_KEY_RING}', 38 | '--key=${_KEY_NAME}', 39 | ] 40 | 41 | # Decrypt the hub (GitHub) credentials. 42 | - name: 'gcr.io/cloud-builders/gcloud' 43 | args: 44 | [ 45 | 'kms', 46 | 'decrypt', 47 | '--ciphertext-file=hub.enc', 48 | '--plaintext-file=hub', 49 | '--location=global', 50 | '--keyring=${_KEY_RING}', 51 | '--key=${_KEY_NAME}', 52 | ] 53 | 54 | # Set up git with key and domain. 55 | - name: 'gcr.io/cloud-builders/git' 56 | entrypoint: 'bash' 57 | args: 58 | - '-c' 59 | - | 60 | chmod 600 /root/.ssh/id_rsa 61 | cat </root/.ssh/config 62 | Hostname github.com 63 | IdentityFile /root/.ssh/id_rsa 64 | EOF 65 | ssh-keyscan github.com >> /root/.ssh/known_hosts 66 | # Clone the repository. 67 | - name: 'gcr.io/cloud-builders/git' 68 | args: ['clone', 'git@github.com:${_REPOSITORY_ORG}/${_REPOSITORY_NAME}'] 69 | 70 | # Set up the Git configuration. 71 | - name: 'gcr.io/cloud-builders/git' 72 | dir: '${_REPOSITORY_NAME}' 73 | args: ['config', '--global', 'user.email', 'firebase-oss-bot@google.com'] 74 | - name: 'gcr.io/cloud-builders/git' 75 | dir: '${_REPOSITORY_NAME}' 76 | args: ['config', '--global', 'user.name', 'Google Open Source Bot'] 77 | 78 | # Set up the Twitter credentials. 79 | - name: 'gcr.io/$PROJECT_ID/fft-package-builder' 80 | entrypoint: 'cp' 81 | args: ['-v', 'twitter.json', '${_REPOSITORY_NAME}/scripts/twitter.json'] 82 | 83 | # Set up the npm credentials. 84 | - name: 'gcr.io/$PROJECT_ID/fft-package-builder' 85 | entrypoint: 'bash' 86 | args: ['-c', 'cp -v npmrc ~/.npmrc'] 87 | 88 | # Set up the hub credentials for fft-package-builder. 89 | - name: 'gcr.io/$PROJECT_ID/fft-package-builder' 90 | entrypoint: 'bash' 91 | args: ['-c', 'mkdir -vp ~/.config && cp -v hub ~/.config/hub'] 92 | 93 | # Publish the package. 94 | - name: 'gcr.io/$PROJECT_ID/fft-package-builder' 95 | dir: '${_REPOSITORY_NAME}' 96 | args: ['bash', './scripts/publish.sh', '${_VERSION}'] 97 | env: 98 | - 'REPOSITORY_ORG=${_REPOSITORY_ORG}' 99 | - 'REPOSITORY_NAME=${_REPOSITORY_NAME}' 100 | - 'DRY_RUN=${_DRY_RUN}' 101 | 102 | options: 103 | volumes: 104 | - name: 'ssh' 105 | path: /root/.ssh 106 | 107 | substitutions: 108 | _VERSION: '' 109 | _DRY_RUN: '' 110 | _KEY_RING: 'npm-publish-keyring' 111 | _KEY_NAME: 'publish' 112 | _REPOSITORY_ORG: 'firebase' 113 | _REPOSITORY_NAME: 'firebase-functions-test' 114 | -------------------------------------------------------------------------------- /scripts/publish/deploy_key.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/firebase-functions-test/fb6cd5ce738231306f9184430f051286f6869dba/scripts/publish/deploy_key.enc -------------------------------------------------------------------------------- /scripts/publish/hub.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/firebase-functions-test/fb6cd5ce738231306f9184430f051286f6869dba/scripts/publish/hub.enc -------------------------------------------------------------------------------- /scripts/publish/npmrc.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/firebase-functions-test/fb6cd5ce738231306f9184430f051286f6869dba/scripts/publish/npmrc.enc -------------------------------------------------------------------------------- /scripts/publish/twitter.json.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/firebase-functions-test/fb6cd5ce738231306f9184430f051286f6869dba/scripts/publish/twitter.json.enc -------------------------------------------------------------------------------- /scripts/tweet.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | const Twitter = require("twitter"); 5 | 6 | function printUsage() { 7 | console.error( 8 | ` 9 | Usage: tweet.js 10 | Credentials must be stored in "twitter.json" in this directory. 11 | Arguments: 12 | - version: Version of module that was released. e.g. "1.2.3" 13 | ` 14 | ); 15 | process.exit(1); 16 | } 17 | 18 | function getUrl(version) { 19 | return `https://github.com/firebase/firebase-functions-test/releases/tag/v${version}`; 20 | } 21 | 22 | if (process.argv.length !== 3) { 23 | console.error("Missing arguments."); 24 | printUsage(); 25 | } 26 | 27 | const version = process.argv.pop(); 28 | if (!version.match(/^\d+\.\d+\.\d+$/)) { 29 | console.error(`Version "${version}" not a version number.`); 30 | printUsage(); 31 | } 32 | 33 | if (!fs.existsSync(`${__dirname}/twitter.json`)) { 34 | console.error("Missing credentials."); 35 | printUsage(); 36 | } 37 | const creds = require("./twitter.json"); 38 | 39 | const client = new Twitter(creds); 40 | 41 | client.post( 42 | "statuses/update", 43 | { status: `v${version} of @Firebase Test SDK for Cloud Functions is available. Release notes: ${getUrl(version)}` }, 44 | (err) => { 45 | if (err) { 46 | console.error(err); 47 | process.exit(1); 48 | } 49 | } 50 | ); 51 | 52 | -------------------------------------------------------------------------------- /spec/app.spec.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { expect } from 'chai'; 24 | import * as sinon from 'sinon'; 25 | import * as firebase from 'firebase-admin'; 26 | 27 | import { testApp } from '../src/app'; 28 | import { FirebaseFunctionsTest } from '../src/lifecycle'; 29 | 30 | describe('app', () => { 31 | let appInstance; 32 | let test; 33 | 34 | before(() => { 35 | test = new FirebaseFunctionsTest(); 36 | test.init(); 37 | appInstance = testApp(); 38 | }); 39 | 40 | after(() => { 41 | test.cleanup(); 42 | }); 43 | 44 | describe('#getApp', () => { 45 | const spy = sinon.spy(firebase, 'initializeApp'); 46 | 47 | afterEach(() => { 48 | spy.resetHistory(); 49 | appInstance.deleteApp(); 50 | }); 51 | 52 | it('should initialize a new app if appSingleton does not exist', () => { 53 | appInstance.getApp(); 54 | expect(spy.called).to.be.true; 55 | }); 56 | 57 | it('should only initialize app once', () => { 58 | appInstance.getApp(); 59 | appInstance.getApp(); 60 | expect(spy.calledOnce).to.be.true; 61 | }); 62 | }); 63 | 64 | describe('#deleteApp', () => { 65 | it('deletes appSingleton if it exists', () => { 66 | const spy = sinon.spy(); 67 | appInstance.appSingleton = { 68 | delete: spy, 69 | }; 70 | appInstance.deleteApp(); 71 | expect(spy.called).to.be.true; 72 | expect(appInstance.appSingleton).to.equal(undefined); 73 | }); 74 | 75 | it('does not throw an error if there are no apps to delete', () => { 76 | delete appInstance.appSingleton; 77 | expect(() => appInstance.deleteApp).to.not.throw(Error); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /spec/cloudevent/generate.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { expect } from 'chai'; 24 | 25 | import { alerts, storage } from 'firebase-functions/v2'; 26 | import { generateMockCloudEvent } from '../../src/cloudevent/generate'; 27 | 28 | describe('generate (CloudEvent)', () => { 29 | describe('#generateMockCloudEvent', () => { 30 | describe('alerts.billing.onPlanAutomatedUpdatePublished()', () => { 31 | it('should create CloudEvent with appropriate fields', () => { 32 | const cloudFn = alerts.billing.onPlanAutomatedUpdatePublished(() => {}); 33 | const cloudEvent = generateMockCloudEvent(cloudFn); 34 | 35 | expect(cloudEvent.type).equal( 36 | 'google.firebase.firebasealerts.alerts.v1.published' 37 | ); 38 | expect(cloudEvent.source).equal( 39 | '//firebasealerts.googleapis.com/projects/42' 40 | ); 41 | expect(cloudEvent.subject).equal(undefined); 42 | }); 43 | }); 44 | describe('storage.onObjectArchived', () => { 45 | it('should create CloudEvent with appropriate fields', () => { 46 | const bucketName = 'bucket_name'; 47 | const cloudFn = storage.onObjectArchived(bucketName, () => {}); 48 | const cloudEvent = generateMockCloudEvent(cloudFn); 49 | 50 | expect(cloudEvent.type).equal( 51 | 'google.cloud.storage.object.v1.archived' 52 | ); 53 | expect(cloudEvent.source).equal( 54 | `//storage.googleapis.com/projects/_/buckets/${bucketName}` 55 | ); 56 | expect(cloudEvent.subject).equal('objects/file_name'); 57 | }); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import 'mocha'; 24 | import { expect } from 'chai'; 25 | 26 | describe('index', () => { 27 | /* tslint:disable-next-line:no-var-requires */ 28 | const indexExport = require('../src')( 29 | { projectId: 'fakeProject' }, 30 | 'fakeServiceAccount' 31 | ); 32 | after(() => { 33 | // Call cleanup (handles case of cleanup function not existing) 34 | indexExport.cleanup && indexExport.cleanup(); 35 | }); 36 | 37 | it('should export the expected functions and namespaces', () => { 38 | expect(Object.getOwnPropertyNames(indexExport).sort()).to.deep.equal([ 39 | 'analytics', 40 | 'auth', 41 | 'cleanup', 42 | 'database', 43 | 'firestore', 44 | 'makeChange', 45 | 'mockConfig', 46 | 'pubsub', 47 | 'storage', 48 | 'wrap', 49 | ]); 50 | }); 51 | 52 | it('should set env variables based parameters SDK was initialized with', () => { 53 | expect(process.env.FIREBASE_CONFIG).to.equal( 54 | JSON.stringify({ projectId: 'fakeProject' }) 55 | ); 56 | expect(process.env.GOOGLE_APPLICATION_CREDENTIALS).to.equal( 57 | 'fakeServiceAccount' 58 | ); 59 | }); 60 | 61 | it('should clean up env variables once cleanup is called', () => { 62 | indexExport.cleanup(); 63 | expect(process.env.FIREBASE_CONFIG).to.equal(undefined); 64 | expect(process.env.GOOGLE_APPLICATION_CREDENTIALS).to.equal(undefined); 65 | }); 66 | }); 67 | 68 | import './lifecycle.spec'; 69 | import './main.spec'; 70 | import './secretmanager.spec'; 71 | import './v2.spec'; 72 | import './cloudevent/generate'; 73 | import './app.spec'; 74 | import './providers/https.spec'; 75 | import './providers/firestore.spec'; 76 | import './providers/database.spec'; 77 | import './providers/scheduled.spec'; 78 | // import './providers/analytics.spec'; 79 | // import './providers/auth.spec'; 80 | // import './providers/https.spec'; 81 | // import './providers/pubsub.spec'; 82 | // import './providers/storage.spec'; 83 | -------------------------------------------------------------------------------- /spec/integration/providers/firestore.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { initializeApp } from 'firebase-admin/app'; 3 | import { getFirestore } from 'firebase-admin/firestore'; 4 | import fft = require('../../../src/index'); 5 | 6 | describe('providers/firestore', () => { 7 | before(() => { 8 | initializeApp(); 9 | }); 10 | 11 | it('clears database with clearFirestoreData', async () => { 12 | const test = fft({ projectId: 'not-a-project' }); 13 | const db = getFirestore(); 14 | 15 | await Promise.all([ 16 | db 17 | .collection('test') 18 | .doc('doc1') 19 | .set({}), 20 | db 21 | .collection('test') 22 | .doc('doc1') 23 | .collection('test') 24 | .doc('doc2') 25 | .set({}), 26 | ]); 27 | 28 | await test.firestore.clearFirestoreData({ projectId: 'not-a-project' }); 29 | 30 | const docs = await Promise.all([ 31 | db 32 | .collection('test') 33 | .doc('doc1') 34 | .get(), 35 | db 36 | .collection('test') 37 | .doc('doc1') 38 | .collection('test') 39 | .doc('doc2') 40 | .get(), 41 | ]); 42 | expect(docs[0].exists).to.be.false; 43 | expect(docs[1].exists).to.be.false; 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /spec/lifecycle.spec.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { expect } from 'chai'; 24 | 25 | import { FirebaseFunctionsTest } from '../src/lifecycle'; 26 | import { mockConfig } from '../src/main'; 27 | import { afterEach } from 'mocha'; 28 | 29 | describe('lifecycle', () => { 30 | describe('#init', () => { 31 | let test; 32 | 33 | before(() => { 34 | test = new FirebaseFunctionsTest(); 35 | }); 36 | 37 | afterEach(() => { 38 | test.cleanup(); 39 | }); 40 | 41 | it('sets env variables appropriately if SDK initialized without parameters', () => { 42 | test.init(); 43 | expect(process.env.FIREBASE_CONFIG).to.equal( 44 | JSON.stringify({ 45 | databaseURL: 'https://not-a-project.firebaseio.com', 46 | storageBucket: 'not-a-project.appspot.com', 47 | projectId: 'not-a-project', 48 | }) 49 | ); 50 | expect(process.env.GCLOUD_PROJECT).to.equal('not-a-project'); 51 | expect(process.env.GOOGLE_APPLICATION_CREDENTIALS).to.be.undefined; 52 | }); 53 | 54 | it('sets env variables appropriately if SDK initialized with parameters', () => { 55 | let firebaseConfig = { 56 | databaseURL: 'https://my-project.firebaseio.com', 57 | storageBucket: 'my-project.appspot.com', 58 | projectId: 'my-project', 59 | }; 60 | test.init(firebaseConfig, 'path/to/key.json'); 61 | 62 | expect(process.env.FIREBASE_CONFIG).to.equal( 63 | JSON.stringify(firebaseConfig) 64 | ); 65 | expect(process.env.GCLOUD_PROJECT).to.equal('my-project'); 66 | expect(process.env.GOOGLE_APPLICATION_CREDENTIALS).to.equal( 67 | 'path/to/key.json' 68 | ); 69 | }); 70 | }); 71 | 72 | describe('#cleanUp', () => { 73 | beforeEach(() => { 74 | delete process.env.FIREBASE_CONFIG; 75 | delete process.env.GCLOUD_PROJECT; 76 | delete process.env.GOOGLE_APPLICATION_CREDENTIALS; 77 | delete process.env.CLOUD_RUNTIME_CONFIG; 78 | }); 79 | 80 | afterEach(() => { 81 | delete process.env.FIREBASE_CONFIG; 82 | delete process.env.GCLOUD_PROJECT; 83 | delete process.env.GOOGLE_APPLICATION_CREDENTIALS; 84 | delete process.env.CLOUD_RUNTIME_CONFIG; 85 | }); 86 | 87 | it('deletes all the env variables if they did not previously exist', () => { 88 | let test = new FirebaseFunctionsTest(); 89 | test.init(); 90 | mockConfig({ foo: { bar: 'faz ' } }); 91 | test.cleanup(); 92 | expect(process.env.FIREBASE_CONFIG).to.be.undefined; 93 | expect(process.env.GCLOUD_PROJECT).to.be.undefined; 94 | expect(process.env.GOOGLE_APPLICATION_CREDENTIALS).to.be.undefined; 95 | expect(process.env.CLOUD_RUNTIME_CONFIG).to.be.undefined; 96 | }); 97 | 98 | it('restores env variables if they had previous values', () => { 99 | process.env.FIREBASE_CONFIG = 'oldFb'; 100 | process.env.GCLOUD_PROJECT = 'oldGc'; 101 | process.env.GOOGLE_APPLICATION_CREDENTIALS = 'oldGac'; 102 | process.env.CLOUD_RUNTIME_CONFIG = 'oldCrc'; 103 | let test = new FirebaseFunctionsTest(); 104 | 105 | test.init(); 106 | mockConfig({ foo: { bar: 'faz ' } }); 107 | test.cleanup(); 108 | 109 | expect(process.env.FIREBASE_CONFIG).to.equal('oldFb'); 110 | expect(process.env.GCLOUD_PROJECT).to.equal('oldGc'); 111 | expect(process.env.GOOGLE_APPLICATION_CREDENTIALS).to.equal('oldGac'); 112 | expect(process.env.CLOUD_RUNTIME_CONFIG).to.equal('oldCrc'); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /spec/main.spec.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { expect } from 'chai'; 24 | import * as functions from 'firebase-functions/v1'; 25 | import { set } from 'lodash'; 26 | 27 | import { mockConfig, makeChange, wrap } from '../src/main'; 28 | import { _makeResourceName, _extractParams } from '../src/v1'; 29 | import { features } from '../src/features'; 30 | import { FirebaseFunctionsTest } from '../src/lifecycle'; 31 | import { alerts } from 'firebase-functions/v2'; 32 | import { wrapV2 } from '../src/v2'; 33 | 34 | describe('main', () => { 35 | describe('#wrap', () => { 36 | describe('background functions', () => { 37 | const constructBackgroundCF = (eventType?: string) => { 38 | const cloudFunction = (input) => input; 39 | set(cloudFunction, 'run', (data, context) => { 40 | return { data, context }; 41 | }); 42 | set(cloudFunction, '__endpoint', { 43 | eventTrigger: { 44 | eventFilters: { 45 | resource: 'ref/{wildcard}/nested/{anotherWildcard}', 46 | }, 47 | eventType: eventType || 'event', 48 | retry: false, 49 | }, 50 | }); 51 | return cloudFunction as functions.CloudFunction; 52 | }; 53 | 54 | it('should invoke the function with the supplied data', () => { 55 | const wrapped = wrap(constructBackgroundCF()); 56 | expect(wrapped('data').data).to.equal('data'); 57 | }); 58 | 59 | it('should generate the appropriate context if no fields specified', () => { 60 | const context = wrap(constructBackgroundCF())('data').context; 61 | expect(typeof context.eventId).to.equal('string'); 62 | expect(context.resource.service).to.equal( 63 | 'unknown-service.googleapis.com' 64 | ); 65 | expect( 66 | /ref\/wildcard[1-9]\/nested\/anotherWildcard[1-9]/.test( 67 | context.resource.name 68 | ) 69 | ).to.be.true; 70 | expect(context.eventType).to.equal('event'); 71 | expect(Date.parse(context.timestamp)).to.be.greaterThan(0); 72 | expect(context.params).to.deep.equal({}); 73 | expect(context.auth).to.be.undefined; 74 | expect(context.authType).to.be.undefined; 75 | }); 76 | 77 | it('should allow specification of context fields', () => { 78 | const wrapped = wrap(constructBackgroundCF()); 79 | const context = wrapped('data', { 80 | eventId: '111', 81 | timestamp: '2018-03-28T18:58:50.370Z', 82 | }).context; 83 | expect(context.eventId).to.equal('111'); 84 | expect(context.timestamp).to.equal('2018-03-28T18:58:50.370Z'); 85 | }); 86 | 87 | describe('database functions', () => { 88 | let test; 89 | let change; 90 | 91 | beforeEach(() => { 92 | test = new FirebaseFunctionsTest(); 93 | test.init(); 94 | change = features.database.exampleDataSnapshotChange(); 95 | }); 96 | 97 | afterEach(() => { 98 | test.cleanup(); 99 | }); 100 | 101 | it('should generate auth and authType', () => { 102 | const wrapped = wrap( 103 | constructBackgroundCF('google.firebase.database.ref.write') 104 | ); 105 | const context = wrapped(change).context; 106 | expect(context.auth).to.equal(null); 107 | expect(context.authType).to.equal('UNAUTHENTICATED'); 108 | }); 109 | 110 | it('should allow auth and authType to be specified', () => { 111 | const wrapped = wrap( 112 | constructBackgroundCF('google.firebase.database.ref.write') 113 | ); 114 | const context = wrapped(change, { 115 | auth: { uid: 'abc' }, 116 | authType: 'USER', 117 | }).context; 118 | expect(context.auth).to.deep.equal({ uid: 'abc' }); 119 | expect(context.authType).to.equal('USER'); 120 | }); 121 | }); 122 | 123 | it('should throw when passed invalid options', () => { 124 | const wrapped = wrap(constructBackgroundCF()); 125 | expect(() => 126 | wrapped('data', { 127 | auth: { uid: 'abc' }, 128 | isInvalid: true, 129 | } as any) 130 | ).to.throw(); 131 | }); 132 | 133 | it('should generate the appropriate resource based on params', () => { 134 | const params = { 135 | wildcard: 'a', 136 | anotherWildcard: 'b', 137 | }; 138 | const wrapped = wrap(constructBackgroundCF()); 139 | const context = wrapped('data', { params }).context; 140 | expect(context.params).to.deep.equal(params); 141 | expect(context.resource.name).to.equal('ref/a/nested/b'); 142 | }); 143 | 144 | describe('Params extraction', () => { 145 | let test; 146 | 147 | beforeEach(() => { 148 | test = new FirebaseFunctionsTest(); 149 | test.init(); 150 | }); 151 | 152 | afterEach(() => { 153 | test.cleanup(); 154 | }); 155 | 156 | it('should extract the appropriate params for database function trigger', () => { 157 | const cf = constructBackgroundCF( 158 | 'google.firebase.database.ref.create' 159 | ); 160 | cf.__endpoint.eventTrigger.eventFilters.resource = 161 | 'companies/{company}/users/{user}'; 162 | const wrapped = wrap(cf); 163 | const context = wrapped( 164 | features.database.makeDataSnapshot( 165 | { foo: 'bar' }, 166 | 'companies/Google/users/Lauren' 167 | ) 168 | ).context; 169 | expect(context.params).to.deep.equal({ 170 | company: 'Google', 171 | user: 'Lauren', 172 | }); 173 | expect(context.resource.name).to.equal( 174 | 'companies/Google/users/Lauren' 175 | ); 176 | }); 177 | 178 | it('should extract the appropriate params for Firestore function trigger', () => { 179 | const cf = constructBackgroundCF('google.firestore.document.create'); 180 | cf.__endpoint.eventTrigger.eventFilters.resource = 181 | 'databases/(default)/documents/companies/{company}/users/{user}'; 182 | const wrapped = wrap(cf); 183 | const context = wrapped( 184 | features.firestore.makeDocumentSnapshot( 185 | { foo: 'bar' }, 186 | 'companies/Google/users/Lauren' 187 | ) 188 | ).context; 189 | expect(context.params).to.deep.equal({ 190 | company: 'Google', 191 | user: 'Lauren', 192 | }); 193 | expect(context.resource.name).to.equal( 194 | 'databases/(default)/documents/companies/Google/users/Lauren' 195 | ); 196 | }); 197 | 198 | it('should prefer provided context.params over the extracted params', () => { 199 | const cf = constructBackgroundCF( 200 | 'google.firebase.database.ref.create' 201 | ); 202 | cf.__endpoint.eventTrigger.eventFilters.resource = 203 | 'companies/{company}/users/{user}'; 204 | const wrapped = wrap(cf); 205 | const context = wrapped( 206 | features.database.makeDataSnapshot( 207 | { foo: 'bar' }, 208 | 'companies/Google/users/Lauren' 209 | ), 210 | { 211 | params: { 212 | company: 'Alphabet', 213 | user: 'Lauren', 214 | foo: 'bar', 215 | }, 216 | } 217 | ).context; 218 | expect(context.params).to.deep.equal({ 219 | company: 'Alphabet', 220 | user: 'Lauren', 221 | foo: 'bar', 222 | }); 223 | expect(context.resource.name).to.equal( 224 | 'companies/Alphabet/users/Lauren' 225 | ); 226 | }); 227 | }); 228 | }); 229 | 230 | describe('v2 functions', () => { 231 | it('should invoke wrapV2 wrapper', () => { 232 | const handler = (cloudEvent) => ({ cloudEvent }); 233 | const cloudFn = alerts.billing.onPlanAutomatedUpdatePublished(handler); 234 | const cloudFnWrap = wrapV2(cloudFn); 235 | 236 | const expectedType = 237 | 'google.firebase.firebasealerts.alerts.v1.published'; 238 | expect(cloudFnWrap().cloudEvent).to.include({ type: expectedType }); 239 | }); 240 | }); 241 | 242 | describe('callable functions', () => { 243 | let wrappedCF; 244 | 245 | before(() => { 246 | const cloudFunction = (input) => input; 247 | set(cloudFunction, 'run', (data, context) => { 248 | return { data, context }; 249 | }); 250 | set(cloudFunction, '__endpoint', { 251 | callableTrigger: {}, 252 | }); 253 | wrappedCF = wrap(cloudFunction as functions.CloudFunction); 254 | }); 255 | 256 | it('should invoke the function with the supplied data', () => { 257 | expect(wrappedCF('data').data).to.equal('data'); 258 | }); 259 | 260 | it('should allow specification of context fields', () => { 261 | const context = wrappedCF('data', { 262 | auth: { uid: 'abc' }, 263 | app: { appId: 'efg' }, 264 | instanceIdToken: '123', 265 | rawRequest: { body: 'hello' }, 266 | }).context; 267 | expect(context.auth).to.deep.equal({ uid: 'abc' }); 268 | expect(context.app).to.deep.equal({ appId: 'efg' }); 269 | expect(context.instanceIdToken).to.equal('123'); 270 | expect(context.rawRequest).to.deep.equal({ body: 'hello' }); 271 | }); 272 | 273 | it('should throw when passed invalid options', () => { 274 | expect(() => 275 | wrappedCF('data', { 276 | auth: { uid: 'abc' }, 277 | isInvalid: true, 278 | } as any) 279 | ).to.throw(); 280 | }); 281 | }); 282 | }); 283 | 284 | describe('#_makeResourceName', () => { 285 | it('constructs the right resource name from params', () => { 286 | const resource = _makeResourceName('companies/{company}/users/{user}', { 287 | company: 'Google', 288 | user: 'Lauren', 289 | }); 290 | expect(resource).to.equal('companies/Google/users/Lauren'); 291 | }); 292 | }); 293 | 294 | describe('#_extractParams', () => { 295 | it('should not extract any params', () => { 296 | const params = _extractParams('users/foo', 'users/foo'); 297 | expect(params).to.deep.equal({}); 298 | }); 299 | 300 | it('should extract params', () => { 301 | const params = _extractParams( 302 | 'companies/{company}/users/{user}', 303 | 'companies/Google/users/Lauren' 304 | ); 305 | expect(params).to.deep.equal({ 306 | company: 'Google', 307 | user: 'Lauren', 308 | }); 309 | }); 310 | }); 311 | 312 | describe('#makeChange', () => { 313 | it('should make a Change object with the correct before and after', () => { 314 | const change = makeChange('before', 'after'); 315 | expect(change instanceof functions.Change).to.be.true; 316 | expect(change.before).to.equal('before'); 317 | expect(change.after).to.equal('after'); 318 | }); 319 | }); 320 | 321 | describe('#mockConfig', () => { 322 | let config: Record; 323 | 324 | beforeEach(() => { 325 | config = { foo: { bar: 'faz ' } }; 326 | }); 327 | 328 | afterEach(() => { 329 | delete process.env.CLOUD_RUNTIME_CONFIG; 330 | }); 331 | 332 | it('should mock functions.config()', () => { 333 | mockConfig(config); 334 | expect(functions.config()).to.deep.equal(config); 335 | }); 336 | 337 | it('should purge singleton config object when it is present', () => { 338 | mockConfig(config); 339 | config.foo = { baz: 'qux' }; 340 | mockConfig(config); 341 | 342 | expect(functions.config()).to.deep.equal(config); 343 | }); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /spec/providers/database.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { FirebaseFunctionsTest } from '../../src/lifecycle'; 3 | import { makeDataSnapshot } from '../../src/providers/database'; 4 | 5 | describe('providers/database', () => { 6 | let test; 7 | 8 | before(() => { 9 | test = new FirebaseFunctionsTest(); 10 | test.init(); 11 | }); 12 | 13 | after(() => { 14 | test.cleanup(); 15 | }); 16 | 17 | it('produces the right snapshot with makeDataSnapshot', async () => { 18 | const snapshot = makeDataSnapshot( 19 | { 20 | foo: 'bar', 21 | }, 22 | 'path' 23 | ); 24 | 25 | expect(snapshot.val()).to.deep.equal({ foo: 'bar' }); 26 | expect(snapshot.ref.key).to.equal('path'); 27 | }); 28 | 29 | it('should allow null value in makeDataSnapshot', async () => { 30 | const snapshot = makeDataSnapshot(null, 'path'); 31 | 32 | expect(snapshot.val()).to.deep.equal(null); 33 | expect(snapshot.ref.key).to.equal('path'); 34 | }); 35 | 36 | it('should use the default test apps databaseURL if no instance is specified in makeDataSnapshot', async () => { 37 | const snapshot = makeDataSnapshot(null, 'path', null); 38 | 39 | expect(snapshot.ref.toString()).to.equal( 40 | 'https://not-a-project.firebaseio.com/path' 41 | ); 42 | }); 43 | 44 | it('should allow different DB instance to be specified in makeDataSnapshot', async () => { 45 | const snapshot = makeDataSnapshot( 46 | null, 47 | 'path', 48 | null, 49 | 'https://another-instance.firebaseio.com' 50 | ); 51 | 52 | expect(snapshot.ref.toString()).to.equal( 53 | 'https://another-instance.firebaseio.com/path' 54 | ); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /spec/providers/firestore.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as firebase from 'firebase-admin'; 3 | import * as sinon from 'sinon'; 4 | import * as http from 'http'; 5 | import { FeaturesList } from '../../src/features'; 6 | import fft = require('../../src/index'); 7 | 8 | describe('providers/firestore', () => { 9 | let test: FeaturesList; 10 | let fakeHttpRequestMethod; 11 | let fakeHttpResponse; 12 | 13 | beforeEach(() => { 14 | test = fft(); 15 | fakeHttpResponse = { 16 | statusCode: 200, 17 | on: (event, cb) => cb(), 18 | }; 19 | fakeHttpRequestMethod = sinon.fake((config, cb) => { 20 | cb(fakeHttpResponse); 21 | }); 22 | sinon.replace(http, 'request', fakeHttpRequestMethod); 23 | }); 24 | 25 | afterEach(() => { 26 | sinon.restore(); 27 | }); 28 | 29 | it('produces the right snapshot with makeDocumentSnapshot', async () => { 30 | const snapshot = test.firestore.makeDocumentSnapshot( 31 | { 32 | email_address: 'test@test.com', 33 | }, 34 | 'collection/doc-id' 35 | ); 36 | 37 | expect(snapshot.data()).to.deep.equal({ 38 | email_address: 'test@test.com', 39 | }); 40 | expect(snapshot.id).to.equal('doc-id'); 41 | }); 42 | 43 | it('should allow empty document in makeDocumentSnapshot', async () => { 44 | const snapshot = test.firestore.makeDocumentSnapshot( 45 | {}, 46 | 'collection/doc-id' 47 | ); 48 | 49 | expect(snapshot.data()).to.deep.equal(undefined); 50 | expect(snapshot.id).to.equal('doc-id'); 51 | }); 52 | 53 | it('should allow geopoints with makeDocumentSnapshot', () => { 54 | const hq = new firebase.firestore.GeoPoint(47.6703, 122.1971); 55 | const snapshot = test.firestore.makeDocumentSnapshot( 56 | { geopoint: hq }, 57 | 'collection/doc-id' 58 | ); 59 | 60 | expect(snapshot.data()).to.deep.equal({ geopoint: hq }); 61 | }); 62 | 63 | it('should allow timestamps with makeDocumentSnapshot', () => { 64 | const time = new Date(); 65 | const snapshot = test.firestore.makeDocumentSnapshot( 66 | { time }, 67 | 'collection/doc-id' 68 | ); 69 | 70 | expect(snapshot.data().time).to.be.instanceof(firebase.firestore.Timestamp); 71 | expect(snapshot.data().time.toDate()).to.deep.equal(time); 72 | }); 73 | 74 | it('should allow references with makeDocumentSnapshot', () => { 75 | firebase.initializeApp({ 76 | projectId: 'not-a-project', 77 | }); 78 | 79 | const ref = firebase.firestore().doc('collection/doc-id'); 80 | const snapshot = test.firestore.makeDocumentSnapshot( 81 | { ref }, 82 | 'collection/doc-id' 83 | ); 84 | 85 | expect(snapshot.data().ref).to.be.instanceOf( 86 | firebase.firestore.DocumentReference 87 | ); 88 | expect(snapshot.data().ref.toString()).to.equal(ref.toString()); 89 | }); 90 | 91 | it('should use host name from FIRESTORE_EMULATOR_HOST env in clearFirestoreData', async () => { 92 | process.env.FIRESTORE_EMULATOR_HOST = 'not-local-host:8080'; 93 | 94 | await test.firestore.clearFirestoreData({ projectId: 'not-a-project' }); 95 | 96 | expect( 97 | fakeHttpRequestMethod.calledOnceWith({ 98 | hostname: 'not-local-host', 99 | method: 'DELETE', 100 | path: 101 | '/emulator/v1/projects/not-a-project/databases/(default)/documents', 102 | port: '8080', 103 | }) 104 | ).to.be.true; 105 | }); 106 | 107 | it('should use host name from FIREBASE_FIRESTORE_EMULATOR_ADDRESS env in clearFirestoreData', async () => { 108 | process.env.FIREBASE_FIRESTORE_EMULATOR_ADDRESS = 'custom-host:9090'; 109 | 110 | await test.firestore.clearFirestoreData({ projectId: 'not-a-project' }); 111 | 112 | expect( 113 | fakeHttpRequestMethod.calledOnceWith({ 114 | hostname: 'custom-host', 115 | method: 'DELETE', 116 | path: 117 | '/emulator/v1/projects/not-a-project/databases/(default)/documents', 118 | port: '9090', 119 | }) 120 | ).to.be.true; 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /spec/providers/https.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as functions from 'firebase-functions/v1'; 3 | import fft = require('../../src/index'); 4 | 5 | const cfToUpperCaseOnRequest = functions.https.onRequest((req, res) => { 6 | res.json({ msg: req.params.message.toUpperCase() }); 7 | }); 8 | 9 | const cfToUpperCaseOnCall = functions.https.onCall((data, context) => { 10 | const result: any = { 11 | msg: data.message.toUpperCase(), 12 | from: 'anonymous', 13 | }; 14 | 15 | if (context.auth && context.auth.uid) { 16 | result.from = context.auth.uid; 17 | } 18 | 19 | if (context.rawRequest) { 20 | result.rawRequest = context.rawRequest; 21 | } 22 | 23 | return result; 24 | }); 25 | 26 | describe('providers/https', () => { 27 | it('should not throw when passed onRequest function', async () => { 28 | const test = fft(); 29 | /* 30 | Note that we must cast the function to any here because onRequst functions 31 | do not fulfill Runnable<>, so these checks are solely for usage of this lib 32 | in JavaScript test suites. 33 | */ 34 | expect(() => test.wrap(cfToUpperCaseOnRequest as any)).to.throw(); 35 | }); 36 | 37 | it('should run the wrapped onCall function and return result', async () => { 38 | const test = fft(); 39 | 40 | const result = await test.wrap(cfToUpperCaseOnCall)({ 41 | message: 'lowercase', 42 | }); 43 | 44 | expect(result).to.deep.equal({ msg: 'LOWERCASE', from: 'anonymous' }); 45 | }); 46 | 47 | it('should accept auth params', async () => { 48 | const test = fft(); 49 | const options = { auth: { uid: 'abc' } }; 50 | 51 | const result = await test.wrap(cfToUpperCaseOnCall)( 52 | { message: 'lowercase' }, 53 | options 54 | ); 55 | 56 | expect(result).to.deep.equal({ msg: 'LOWERCASE', from: 'abc' }); 57 | }); 58 | 59 | it('should accept raw request', async () => { 60 | const mockRequest: any = (sessionData) => { 61 | return { 62 | session: { data: sessionData }, 63 | }; 64 | }; 65 | mockRequest.rawBody = Buffer.from('foobar'); 66 | const test = fft(); 67 | const options = { 68 | rawRequest: mockRequest, 69 | }; 70 | 71 | const result = await test.wrap(cfToUpperCaseOnCall)( 72 | { message: 'lowercase' }, 73 | options 74 | ); 75 | 76 | expect(result).to.deep.equal({ 77 | msg: 'LOWERCASE', 78 | from: 'anonymous', 79 | rawRequest: mockRequest, 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /spec/providers/scheduled.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon'; 2 | import * as functions from 'firebase-functions/v1'; 3 | import fft = require('../../src/index'); 4 | import { WrappedScheduledFunction } from '../../src/main'; 5 | 6 | describe('providers/scheduled', () => { 7 | const fakeFn = sinon.fake.resolves(); 8 | const scheduledFunc = functions.pubsub 9 | .schedule('every 2 hours') 10 | .onRun(fakeFn); 11 | 12 | const emptyObjectMatcher = sinon.match( 13 | (v) => sinon.match.object.test(v) && Object.keys(v).length === 0 14 | ); 15 | 16 | afterEach(() => { 17 | fakeFn.resetHistory(); 18 | }); 19 | 20 | it('should run the wrapped function with generated context', async () => { 21 | const test = fft(); 22 | const fn: WrappedScheduledFunction = test.wrap(scheduledFunc); 23 | await fn(); 24 | // Function should only be called with 1 argument 25 | sinon.assert.calledOnce(fakeFn); 26 | sinon.assert.calledWithExactly( 27 | fakeFn, 28 | sinon.match({ 29 | eventType: sinon.match.string, 30 | timestamp: sinon.match.string, 31 | params: emptyObjectMatcher, 32 | }) 33 | ); 34 | }); 35 | 36 | it('should run the wrapped function with provided context', async () => { 37 | const timestamp = new Date().toISOString(); 38 | const test = fft(); 39 | const fn: WrappedScheduledFunction = test.wrap(scheduledFunc); 40 | await fn({ timestamp }); 41 | sinon.assert.calledOnce(fakeFn); 42 | sinon.assert.calledWithExactly( 43 | fakeFn, 44 | sinon.match({ 45 | eventType: sinon.match.string, 46 | timestamp, 47 | params: emptyObjectMatcher, 48 | }) 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /spec/secretmanager.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { mockSecretManager } from '../src/secretManager'; 3 | 4 | describe('mockSecretManager', () => { 5 | let originalEnv; 6 | 7 | before(() => { 8 | // Capture the original environment variables 9 | originalEnv = { ...process.env }; 10 | }); 11 | 12 | afterEach(() => { 13 | // Reset any mutations made by the test run 14 | process.env = { ...originalEnv }; 15 | }); 16 | 17 | it('applies each key/value pair to process.env', () => { 18 | const conf = { FOO: 'bar', BAZ: 'qux' }; 19 | 20 | mockSecretManager(conf); 21 | 22 | expect(process.env.FOO).to.equal('bar'); 23 | expect(process.env.BAZ).to.equal('qux'); 24 | }); 25 | 26 | it('overwrites an existing variable with the new value', () => { 27 | process.env.EXISTING = 'old'; 28 | const conf = { EXISTING: 'new' }; 29 | 30 | mockSecretManager(conf); 31 | 32 | expect(process.env.EXISTING).to.equal('new'); 33 | }); 34 | 35 | it('supports non-string values (coerced to string)', () => { 36 | const conf: Record = { 37 | NUM_VALUE: '123', 38 | BOOL_VALUE: 'true', 39 | }; 40 | 41 | mockSecretManager(conf); 42 | 43 | expect(process.env.NUM_VALUE).to.equal('123'); 44 | expect(process.env.BOOL_VALUE).to.equal('true'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import * as firebase from 'firebase-admin'; 24 | 25 | /** @internal */ 26 | export function testApp(): testApp.App { 27 | if (typeof testApp.singleton === 'undefined') { 28 | testApp.init(); 29 | } 30 | return testApp.singleton; 31 | } 32 | 33 | /** @internal */ 34 | export namespace testApp { 35 | export let singleton: testApp.App; 36 | export let init = () => (singleton = new testApp.App()); 37 | 38 | export class App { 39 | appSingleton: firebase.app.App; 40 | constructor() {} 41 | 42 | getApp(): firebase.app.App { 43 | if (typeof this.appSingleton === 'undefined') { 44 | const config = process.env.FIREBASE_CONFIG 45 | ? JSON.parse(process.env.FIREBASE_CONFIG) 46 | : {}; 47 | this.appSingleton = firebase.initializeApp( 48 | config, 49 | // Give this app a name so it does not conflict with apps that user initialized. 50 | 'firebase-functions-test' 51 | ); 52 | } 53 | return this.appSingleton; 54 | } 55 | 56 | deleteApp() { 57 | if (this.appSingleton) { 58 | this.appSingleton.delete(); 59 | delete this.appSingleton; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/cloudevent/generate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CloudEvent, 3 | CloudFunction, 4 | database, 5 | pubsub, 6 | } from 'firebase-functions/v2'; 7 | import { 8 | DocumentSnapshot, 9 | QueryDocumentSnapshot, 10 | } from 'firebase-admin/firestore'; 11 | import { LIST_OF_MOCK_CLOUD_EVENT_PARTIALS } from './mocks/partials'; 12 | import { DeepPartial } from './types'; 13 | import { Change } from 'firebase-functions/v1'; 14 | import merge from 'ts-deepmerge'; 15 | 16 | /** 17 | * @return {CloudEvent} Generated Mock CloudEvent 18 | */ 19 | export function generateCombinedCloudEvent< 20 | EventType extends CloudEvent 21 | >( 22 | cloudFunction: CloudFunction, 23 | cloudEventPartial?: DeepPartial 24 | ): EventType { 25 | const generatedCloudEvent = generateMockCloudEvent( 26 | cloudFunction, 27 | cloudEventPartial 28 | ); 29 | return mergeCloudEvents(generatedCloudEvent, cloudEventPartial); 30 | } 31 | 32 | export function generateMockCloudEvent>( 33 | cloudFunction: CloudFunction, 34 | cloudEventPartial?: DeepPartial 35 | ): EventType { 36 | for (const mockCloudEventPartial of LIST_OF_MOCK_CLOUD_EVENT_PARTIALS) { 37 | if (mockCloudEventPartial.match(cloudFunction)) { 38 | return mockCloudEventPartial.generateMock( 39 | cloudFunction, 40 | cloudEventPartial 41 | ); 42 | } 43 | } 44 | // No matches were found 45 | return null; 46 | } 47 | 48 | const IMMUTABLE_DATA_TYPES = [ 49 | database.DataSnapshot, 50 | DocumentSnapshot, 51 | QueryDocumentSnapshot, 52 | Change, 53 | pubsub.Message, 54 | ]; 55 | 56 | function mergeCloudEvents>( 57 | generatedCloudEvent: EventType, 58 | cloudEventPartial: DeepPartial 59 | ) { 60 | /** 61 | * There are several CloudEvent.data types that can not be overridden with json. 62 | * In these circumstances, we generate the CloudEvent.data given the user supplies 63 | * in the DeepPartial. 64 | * 65 | * Because we have already extracted the user supplied data, we don't want to overwrite 66 | * the CloudEvent.data with an incompatible type. 67 | * 68 | * An example of this is a user supplying JSON for the data of the DatabaseEvents. 69 | * The returned CloudEvent should be returning DataSnapshot that uses the supplied json, 70 | * NOT the supplied JSON. 71 | */ 72 | if (shouldDeleteUserSuppliedData(generatedCloudEvent, cloudEventPartial)) { 73 | delete cloudEventPartial.data; 74 | } 75 | return cloudEventPartial 76 | ? (merge(generatedCloudEvent, cloudEventPartial) as EventType) 77 | : generatedCloudEvent; 78 | } 79 | 80 | function shouldDeleteUserSuppliedData>( 81 | generatedCloudEvent: EventType, 82 | cloudEventPartial: DeepPartial 83 | ) { 84 | // Don't attempt to delete the data if there is no data. 85 | if (cloudEventPartial?.data === undefined) { 86 | return false; 87 | } 88 | // If the user intentionally provides one of the IMMUTABLE DataTypes, DON'T delete it! 89 | if ( 90 | IMMUTABLE_DATA_TYPES.some((type) => cloudEventPartial?.data instanceof type) 91 | ) { 92 | return false; 93 | } 94 | 95 | /** If the generated CloudEvent.data is an IMMUTABLE DataTypes, then use the generated data and 96 | * delete the user supplied CloudEvent.data. 97 | */ 98 | if ( 99 | IMMUTABLE_DATA_TYPES.some( 100 | (type) => generatedCloudEvent?.data instanceof type 101 | ) 102 | ) { 103 | return true; 104 | } 105 | 106 | // Otherwise, don't delete the data and allow ts-merge to handle merging the data. 107 | return false; 108 | } 109 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/alerts-on-alert-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { 4 | APP_ID, 5 | getBaseCloudEvent, 6 | getEventType, 7 | PROJECT_ID, 8 | } from '../helpers'; 9 | import { FirebaseAlertData, AlertEvent } from 'firebase-functions/v2/alerts'; 10 | 11 | export const alertsOnAlertPublished: MockCloudEventAbstractFactory> = { 14 | generateMock( 15 | cloudFunction: CloudFunction> 16 | ): AlertEvent { 17 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 18 | 19 | const alertType = 'appDistribution.newTesterIosDevice'; 20 | const appId = APP_ID; 21 | 22 | return { 23 | // Spread common fields 24 | ...getBaseCloudEvent(cloudFunction), 25 | // Spread fields specific to this CloudEvent 26 | 27 | alertType, 28 | appId, 29 | data: getOnAlertPublishedData(), 30 | source, 31 | }; 32 | }, 33 | match(cloudFunction: CloudFunction>): boolean { 34 | return ( 35 | getEventType(cloudFunction) === 36 | 'google.firebase.firebasealerts.alerts.v1.published' 37 | ); 38 | }, 39 | }; 40 | 41 | /** Alert Published Data */ 42 | 43 | function getOnAlertPublishedData(): FirebaseAlertData { 44 | const now = new Date().toISOString(); 45 | return { 46 | createTime: now, 47 | endTime: now, 48 | payload: {}, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/app-distribution-on-new-tester-ios-device-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { 4 | getBaseCloudEvent, 5 | getEventFilters, 6 | getEventType, 7 | PROJECT_ID, 8 | } from '../helpers'; 9 | import { 10 | AppDistributionEvent, 11 | NewTesterDevicePayload, 12 | } from 'firebase-functions/v2/alerts/appDistribution'; 13 | 14 | export const alertsAppDistributionOnNewTesterIosDevicePublished: MockCloudEventAbstractFactory> = { 17 | generateMock( 18 | cloudFunction: CloudFunction> 19 | ): AppDistributionEvent { 20 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 21 | const now = new Date().toISOString(); 22 | 23 | return { 24 | // Spread common fields 25 | ...getBaseCloudEvent(cloudFunction), 26 | // Spread fields specific to this CloudEvent 27 | source, 28 | data: { 29 | createTime: now, 30 | endTime: now, 31 | payload: { 32 | ['@type']: 33 | 'type.googleapis.com/google.events.firebase.firebasealerts.v1.AppDistroNewTesterIosDevicePayload', 34 | testerName: 'tester name', 35 | testerEmail: 'test@test.com', 36 | testerDeviceModelName: 'tester device model name', 37 | testerDeviceIdentifier: 'tester device identifier', 38 | }, 39 | }, 40 | }; 41 | }, 42 | match( 43 | cloudFunction: CloudFunction> 44 | ): boolean { 45 | return ( 46 | getEventType(cloudFunction) === 47 | 'google.firebase.firebasealerts.alerts.v1.published' && 48 | getEventFilters(cloudFunction)?.alerttype === 49 | 'appDistribution.newTesterIosDevice' 50 | ); 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/billing-on-plan-automated-update-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { FirebaseAlertData } from 'firebase-functions/v2/alerts'; 4 | import { 5 | BillingEvent, 6 | PlanAutomatedUpdatePayload, 7 | } from 'firebase-functions/v2/alerts/billing'; 8 | import { 9 | getBaseCloudEvent, 10 | getEventFilters, 11 | getEventType, 12 | PROJECT_ID, 13 | } from '../helpers'; 14 | 15 | export const alertsBillingOnPlanAutomatedUpdatePublished: MockCloudEventAbstractFactory> = { 18 | generateMock( 19 | cloudFunction: CloudFunction> 20 | ): BillingEvent { 21 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 22 | 23 | return { 24 | // Spread common fields 25 | ...getBaseCloudEvent(cloudFunction), 26 | // Spread fields specific to this CloudEvent 27 | source, 28 | data: getBillingPlanAutomatedUpdateData(), 29 | }; 30 | }, 31 | match( 32 | cloudFunction: CloudFunction> 33 | ): boolean { 34 | return ( 35 | getEventType(cloudFunction) === 36 | 'google.firebase.firebasealerts.alerts.v1.published' && 37 | getEventFilters(cloudFunction)?.alerttype === 38 | 'billing.planAutomatedUpdate' 39 | ); 40 | }, 41 | }; 42 | 43 | function getBillingPlanAutomatedUpdateData(): FirebaseAlertData< 44 | PlanAutomatedUpdatePayload 45 | > { 46 | const now = new Date().toISOString(); 47 | return { 48 | createTime: now, 49 | endTime: now, 50 | payload: { 51 | ['@type']: 52 | 'type.googleapis.com/google.events.firebase.firebasealerts.v1.BillingPlanAutomatedUpdatePayload', 53 | billingPlan: 'flame', 54 | notificationType: 'upgrade', 55 | }, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/billing-on-plan-update-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { FirebaseAlertData } from 'firebase-functions/v2/alerts'; 4 | import { 5 | BillingEvent, 6 | PlanUpdatePayload, 7 | } from 'firebase-functions/v2/alerts/billing'; 8 | import { 9 | getBaseCloudEvent, 10 | getEventFilters, 11 | getEventType, 12 | PROJECT_ID, 13 | } from '../helpers'; 14 | 15 | export const alertsBillingOnPlanUpdatePublished: MockCloudEventAbstractFactory> = { 18 | generateMock( 19 | cloudFunction: CloudFunction> 20 | ): BillingEvent { 21 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 22 | 23 | return { 24 | // Spread common fields 25 | ...getBaseCloudEvent(cloudFunction), 26 | // Spread fields specific to this CloudEvent 27 | source, 28 | data: getBillingPlanUpdateData(), 29 | }; 30 | }, 31 | match( 32 | cloudFunction: CloudFunction> 33 | ): boolean { 34 | return ( 35 | getEventType(cloudFunction) === 36 | 'google.firebase.firebasealerts.alerts.v1.published' && 37 | getEventFilters(cloudFunction)?.alerttype === 'billing.planUpdate' 38 | ); 39 | }, 40 | }; 41 | 42 | /** Alert Billing Data */ 43 | function getBillingPlanUpdateData(): FirebaseAlertData { 44 | const now = new Date().toISOString(); 45 | return { 46 | createTime: now, 47 | endTime: now, 48 | payload: { 49 | ['@type']: 50 | 'type.googleapis.com/google.events.firebase.firebasealerts.v1.BillingPlanUpdatePayload', 51 | billingPlan: 'flame', 52 | principalEmail: 'test@test.com', 53 | notificationType: 'upgrade', 54 | }, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/crashlytics-on-new-anr-issue-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { 4 | getBaseCloudEvent, 5 | getEventFilters, 6 | getEventType, 7 | PROJECT_ID, 8 | } from '../helpers'; 9 | import { 10 | CrashlyticsEvent, 11 | NewAnrIssuePayload, 12 | } from 'firebase-functions/v2/alerts/crashlytics'; 13 | import { FirebaseAlertData } from 'firebase-functions/v2/alerts'; 14 | 15 | export const alertsCrashlyticsOnNewAnrIssuePublished: MockCloudEventAbstractFactory> = { 18 | generateMock( 19 | cloudFunction: CloudFunction> 20 | ): CrashlyticsEvent { 21 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 22 | 23 | return { 24 | // Spread common fields 25 | ...getBaseCloudEvent(cloudFunction), 26 | // Spread fields specific to this CloudEvent 27 | source, 28 | data: getCrashlyticsNewAnrIssueData(), 29 | }; 30 | }, 31 | match( 32 | cloudFunction: CloudFunction> 33 | ): boolean { 34 | return ( 35 | getEventType(cloudFunction) === 36 | 'google.firebase.firebasealerts.alerts.v1.published' && 37 | getEventFilters(cloudFunction)?.alerttype === 'crashlytics.newAnrIssue' 38 | ); 39 | }, 40 | }; 41 | 42 | function getCrashlyticsNewAnrIssueData(): FirebaseAlertData< 43 | NewAnrIssuePayload 44 | > { 45 | const now = new Date().toISOString(); 46 | return { 47 | createTime: now, 48 | endTime: now, 49 | payload: { 50 | ['@type']: 51 | 'type.googleapis.com/google.events.firebase.firebasealerts.v1.CrashlyticsNewAnrIssuePayload', 52 | issue: { 53 | id: 'crashlytics_issue_id', 54 | title: 'crashlytics_issue_title', 55 | subtitle: 'crashlytics_issue_subtitle', 56 | appVersion: 'crashlytics_issue_app_version', 57 | }, 58 | }, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/crashlytics-on-new-fatal-issue-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { 4 | getBaseCloudEvent, 5 | getEventFilters, 6 | getEventType, 7 | PROJECT_ID, 8 | } from '../helpers'; 9 | import { 10 | CrashlyticsEvent, 11 | NewFatalIssuePayload, 12 | } from 'firebase-functions/v2/alerts/crashlytics'; 13 | import { FirebaseAlertData } from 'firebase-functions/v2/alerts'; 14 | 15 | export const alertsCrashlyticsOnNewFatalIssuePublished: MockCloudEventAbstractFactory> = { 18 | generateMock( 19 | cloudFunction: CloudFunction> 20 | ): CrashlyticsEvent { 21 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 22 | 23 | return { 24 | // Spread common fields 25 | ...getBaseCloudEvent(cloudFunction), 26 | // Spread fields specific to this CloudEvent 27 | source, 28 | data: getCrashlyticsNewFatalIssueData(), 29 | }; 30 | }, 31 | match( 32 | cloudFunction: CloudFunction> 33 | ): boolean { 34 | return ( 35 | getEventType(cloudFunction) === 36 | 'google.firebase.firebasealerts.alerts.v1.published' && 37 | getEventFilters(cloudFunction)?.alerttype === 'crashlytics.newFatalIssue' 38 | ); 39 | }, 40 | }; 41 | 42 | function getCrashlyticsNewFatalIssueData(): FirebaseAlertData< 43 | NewFatalIssuePayload 44 | > { 45 | const now = new Date().toISOString(); 46 | return { 47 | createTime: now, 48 | endTime: now, 49 | payload: { 50 | ['@type']: 51 | 'type.googleapis.com/google.events.firebase.firebasealerts.v1.CrashlyticsNewFatalIssuePayload', 52 | issue: { 53 | id: 'crashlytics_issue_id', 54 | title: 'crashlytics_issue_title', 55 | subtitle: 'crashlytics_issue_subtitle', 56 | appVersion: 'crashlytics_issue_app_version', 57 | }, 58 | }, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/crashlytics-on-new-nonfatal-issue-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { 4 | getBaseCloudEvent, 5 | getEventFilters, 6 | getEventType, 7 | PROJECT_ID, 8 | } from '../helpers'; 9 | import { 10 | CrashlyticsEvent, 11 | NewNonfatalIssuePayload, 12 | } from 'firebase-functions/v2/alerts/crashlytics'; 13 | import { FirebaseAlertData } from 'firebase-functions/v2/alerts'; 14 | 15 | export const alertsCrashlyticsOnNewNonfatalIssuePublished: MockCloudEventAbstractFactory> = { 18 | generateMock( 19 | cloudFunction: CloudFunction> 20 | ): CrashlyticsEvent { 21 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 22 | 23 | return { 24 | // Spread common fields 25 | ...getBaseCloudEvent(cloudFunction), 26 | // Spread fields specific to this CloudEvent 27 | source, 28 | data: getCrashlyticsNewNonfatalIssueData(), 29 | }; 30 | }, 31 | match( 32 | cloudFunction: CloudFunction> 33 | ): boolean { 34 | return ( 35 | getEventType(cloudFunction) === 36 | 'google.firebase.firebasealerts.alerts.v1.published' && 37 | getEventFilters(cloudFunction)?.alerttype === 38 | 'crashlytics.newNonfatalIssue' 39 | ); 40 | }, 41 | }; 42 | 43 | function getCrashlyticsNewNonfatalIssueData(): FirebaseAlertData< 44 | NewNonfatalIssuePayload 45 | > { 46 | const now = new Date().toISOString(); 47 | return { 48 | createTime: now, 49 | endTime: now, 50 | payload: { 51 | ['@type']: 52 | 'type.googleapis.com/google.events.firebase.firebasealerts.v1.CrashlyticsNewNonfatalIssuePayload', 53 | issue: { 54 | id: 'crashlytics_issue_id', 55 | title: 'crashlytics_issue_title', 56 | subtitle: 'crashlytics_issue_subtitle', 57 | appVersion: 'crashlytics_issue_app_version', 58 | }, 59 | }, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/crashlytics-on-regression-alert-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { 4 | getBaseCloudEvent, 5 | getEventFilters, 6 | getEventType, 7 | PROJECT_ID, 8 | } from '../helpers'; 9 | import { 10 | CrashlyticsEvent, 11 | RegressionAlertPayload, 12 | } from 'firebase-functions/v2/alerts/crashlytics'; 13 | import { FirebaseAlertData } from 'firebase-functions/v2/alerts'; 14 | 15 | export const alertsCrashlyticsOnRegressionAlertPublished: MockCloudEventAbstractFactory> = { 18 | generateMock( 19 | cloudFunction: CloudFunction> 20 | ): CrashlyticsEvent { 21 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 22 | 23 | return { 24 | // Spread common fields 25 | ...getBaseCloudEvent(cloudFunction), 26 | // Spread fields specific to this CloudEvent 27 | source, 28 | data: getCrashlyticsRegressionAlertPayload(), 29 | }; 30 | }, 31 | match( 32 | cloudFunction: CloudFunction> 33 | ): boolean { 34 | return ( 35 | getEventType(cloudFunction) === 36 | 'google.firebase.firebasealerts.alerts.v1.published' && 37 | getEventFilters(cloudFunction)?.alerttype === 'crashlytics.regression' 38 | ); 39 | }, 40 | }; 41 | 42 | /** Alert Crashlytics Data */ 43 | function getCrashlyticsRegressionAlertPayload(): FirebaseAlertData< 44 | RegressionAlertPayload 45 | > { 46 | const now = new Date().toISOString(); 47 | return { 48 | createTime: now, 49 | endTime: now, 50 | payload: { 51 | ['@type']: 52 | 'type.googleapis.com/google.events.firebase.firebasealerts.v1.CrashlyticsRegressionAlertPayload', 53 | issue: { 54 | id: 'crashlytics_issue_id', 55 | title: 'crashlytics_issue_title', 56 | subtitle: 'crashlytics_issue_subtitle', 57 | appVersion: 'crashlytics_issue_app_version', 58 | }, 59 | type: 'test type', 60 | resolveTime: now, 61 | }, 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/crashlytics-on-stability-digest-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { 4 | getBaseCloudEvent, 5 | getEventFilters, 6 | getEventType, 7 | PROJECT_ID, 8 | } from '../helpers'; 9 | import { 10 | CrashlyticsEvent, 11 | StabilityDigestPayload, 12 | } from 'firebase-functions/v2/alerts/crashlytics'; 13 | import { FirebaseAlertData } from 'firebase-functions/v2/alerts'; 14 | 15 | export const alertsCrashlyticsOnStabilityDigestPublished: MockCloudEventAbstractFactory> = { 18 | generateMock( 19 | cloudFunction: CloudFunction> 20 | ): CrashlyticsEvent { 21 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 22 | 23 | return { 24 | // Spread common fields 25 | ...getBaseCloudEvent(cloudFunction), 26 | // Spread fields specific to this CloudEvent 27 | source, 28 | data: getCrashlyticsStabilityData(), 29 | }; 30 | }, 31 | match( 32 | cloudFunction: CloudFunction> 33 | ): boolean { 34 | return ( 35 | getEventType(cloudFunction) === 36 | 'google.firebase.firebasealerts.alerts.v1.published' && 37 | getEventFilters(cloudFunction)?.alerttype === 38 | 'crashlytics.stabilityDigest' 39 | ); 40 | }, 41 | }; 42 | 43 | function getCrashlyticsStabilityData(): FirebaseAlertData< 44 | StabilityDigestPayload 45 | > { 46 | const now = new Date().toISOString(); 47 | return { 48 | createTime: now, 49 | endTime: now, 50 | payload: { 51 | ['@type']: 52 | 'type.googleapis.com/google.events.firebase.firebasealerts.v1.CrashlyticsStabilityDigestPayload', 53 | digestDate: new Date().toISOString(), 54 | trendingIssues: [ 55 | { 56 | type: 'type', 57 | eventCount: 100, 58 | userCount: 100, 59 | issue: { 60 | id: 'crashlytics_issue_id', 61 | title: 'crashlytics_issue_title', 62 | subtitle: 'crashlytics_issue_subtitle', 63 | appVersion: 'crashlytics_issue_app_version', 64 | }, 65 | }, 66 | ], 67 | }, 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/crashlytics-on-velocity-alert-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { 4 | getBaseCloudEvent, 5 | getEventFilters, 6 | getEventType, 7 | PROJECT_ID, 8 | } from '../helpers'; 9 | import { 10 | CrashlyticsEvent, 11 | VelocityAlertPayload, 12 | } from 'firebase-functions/v2/alerts/crashlytics'; 13 | import { FirebaseAlertData } from 'firebase-functions/v2/alerts'; 14 | 15 | export const alertsCrashlyticsOnVelocityAlertPublished: MockCloudEventAbstractFactory> = { 18 | generateMock( 19 | cloudFunction: CloudFunction> 20 | ): CrashlyticsEvent { 21 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 22 | 23 | return { 24 | // Spread common fields 25 | ...getBaseCloudEvent(cloudFunction), 26 | // Spread fields specific to this CloudEvent 27 | source, 28 | data: getCrashlyticsVelocityAlertData(), 29 | }; 30 | }, 31 | match( 32 | cloudFunction: CloudFunction> 33 | ): boolean { 34 | return ( 35 | getEventType(cloudFunction) === 36 | 'google.firebase.firebasealerts.alerts.v1.published' && 37 | getEventFilters(cloudFunction)?.alerttype === 'crashlytics.velocity' 38 | ); 39 | }, 40 | }; 41 | 42 | function getCrashlyticsVelocityAlertData(): FirebaseAlertData< 43 | VelocityAlertPayload 44 | > { 45 | const now = new Date().toISOString(); 46 | return { 47 | createTime: now, 48 | endTime: now, 49 | payload: { 50 | ['@type']: 51 | 'type.googleapis.com/google.events.firebase.firebasealerts.v1.CrashlyticsVelocityAlertPayload', 52 | crashCount: 100, 53 | issue: { 54 | id: 'crashlytics_issue_id', 55 | title: 'crashlytics_issue_title', 56 | subtitle: 'crashlytics_issue_subtitle', 57 | appVersion: 'crashlytics_issue_app_version', 58 | }, 59 | createTime: now, 60 | firstVersion: '1.1', 61 | crashPercentage: 50.0, 62 | }, 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/alerts/performance-on-threshold-alert-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction } from 'firebase-functions/v2'; 3 | import { 4 | PerformanceEvent, 5 | ThresholdAlertPayload, 6 | } from 'firebase-functions/v2/alerts/performance'; 7 | import { 8 | getBaseCloudEvent, 9 | getEventFilters, 10 | getEventType, 11 | PROJECT_ID, 12 | APP_ID, 13 | } from '../helpers'; 14 | import { FirebaseAlertData } from 'firebase-functions/v2/alerts'; 15 | 16 | export const performanceThresholdOnThresholdAlertPublished: MockCloudEventAbstractFactory> = { 19 | generateMock( 20 | cloudFunction: CloudFunction> 21 | ): PerformanceEvent { 22 | const source = `//firebasealerts.googleapis.com/projects/${PROJECT_ID}`; 23 | const alertType = 'performance.threshold'; 24 | const appId = APP_ID; 25 | return { 26 | ...getBaseCloudEvent(cloudFunction), 27 | alertType, 28 | appId, 29 | source, 30 | data: getThresholdAlertPayload(), 31 | }; 32 | }, 33 | match( 34 | cloudFunction: CloudFunction> 35 | ): boolean { 36 | return ( 37 | getEventType(cloudFunction) === 38 | 'google.firebase.firebasealerts.alerts.v1.published' && 39 | getEventFilters(cloudFunction)?.alerttype === 'performance.threshold' 40 | ); 41 | }, 42 | }; 43 | 44 | function getThresholdAlertPayload(): FirebaseAlertData { 45 | const now = new Date().toISOString(); 46 | return { 47 | createTime: now, 48 | endTime: now, 49 | payload: { 50 | eventName: 'test.com/api/123', 51 | eventType: 'network_request', 52 | metricType: 'duration', 53 | numSamples: 200, 54 | thresholdValue: 100, 55 | thresholdUnit: 'ms', 56 | violationValue: 200, 57 | violationUnit: 'ms', 58 | investigateUri: 'firebase.google.com/firebase/console', 59 | }, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/database/database-on-value-created.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction, database } from 'firebase-functions/v2'; 3 | import { getEventType } from '../helpers'; 4 | import { getDatabaseSnapshotCloudEvent } from './helpers'; 5 | 6 | export const databaseOnValueCreated: MockCloudEventAbstractFactory> = { 9 | generateMock: getDatabaseSnapshotCloudEvent, 10 | match(cloudFunction: CloudFunction>): boolean { 11 | return ( 12 | getEventType(cloudFunction) === 'google.firebase.database.ref.v1.created' 13 | ); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/database/database-on-value-deleted.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction, database } from 'firebase-functions/v2'; 3 | import { getEventType } from '../helpers'; 4 | import { getDatabaseSnapshotCloudEvent } from './helpers'; 5 | 6 | export const databaseOnValueDeleted: MockCloudEventAbstractFactory> = { 9 | generateMock: getDatabaseSnapshotCloudEvent, 10 | match(cloudFunction: CloudFunction>): boolean { 11 | return ( 12 | getEventType(cloudFunction) === 'google.firebase.database.ref.v1.deleted' 13 | ); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/database/database-on-value-updated.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction, database } from 'firebase-functions/v2'; 3 | import { getEventType } from '../helpers'; 4 | import { Change } from 'firebase-functions/v1'; 5 | import { getDatabaseChangeSnapshotCloudEvent } from './helpers'; 6 | 7 | export const databaseOnValueUpdated: MockCloudEventAbstractFactory 9 | >> = { 10 | generateMock: getDatabaseChangeSnapshotCloudEvent, 11 | match(cloudFunction: CloudFunction>): boolean { 12 | return ( 13 | getEventType(cloudFunction) === 'google.firebase.database.ref.v1.updated' 14 | ); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/database/database-on-value-written.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction, database } from 'firebase-functions/v2'; 3 | import { getEventType } from '../helpers'; 4 | import { Change } from 'firebase-functions/v1'; 5 | import { getDatabaseChangeSnapshotCloudEvent } from './helpers'; 6 | 7 | export const databaseOnValueWritten: MockCloudEventAbstractFactory 9 | >> = { 10 | generateMock: getDatabaseChangeSnapshotCloudEvent, 11 | match(cloudFunction: CloudFunction>): boolean { 12 | return ( 13 | getEventType(cloudFunction) === 'google.firebase.database.ref.v1.written' 14 | ); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/database/helpers.ts: -------------------------------------------------------------------------------- 1 | import { CloudFunction, database } from 'firebase-functions/v2'; 2 | import { DeepPartial } from '../../types'; 3 | import { 4 | exampleDataSnapshot, 5 | exampleDataSnapshotChange, 6 | } from '../../../providers/database'; 7 | import { 8 | resolveStringExpression, 9 | getBaseCloudEvent, 10 | extractRef, 11 | } from '../helpers'; 12 | import { Change } from 'firebase-functions/v1'; 13 | import { makeDataSnapshot } from '../../../providers/database'; 14 | 15 | type ChangeLike = { 16 | before: database.DataSnapshot | object; 17 | after: database.DataSnapshot | object; 18 | }; 19 | 20 | function getOrCreateDataSnapshot( 21 | data: database.DataSnapshot | object, 22 | ref: string 23 | ) { 24 | if (data instanceof database.DataSnapshot) { 25 | return data; 26 | } 27 | if (data instanceof Object) { 28 | return makeDataSnapshot(data, ref); 29 | } 30 | return exampleDataSnapshot(ref); 31 | } 32 | 33 | function getOrCreateDataSnapshotChange( 34 | data: DeepPartial | ChangeLike>, 35 | ref: string 36 | ) { 37 | if (data instanceof Change) { 38 | return data; 39 | } 40 | if (data instanceof Object && data?.before && data?.after) { 41 | const beforeDataSnapshot = getOrCreateDataSnapshot(data!.before, ref); 42 | const afterDataSnapshot = getOrCreateDataSnapshot(data!.after, ref); 43 | return new Change(beforeDataSnapshot, afterDataSnapshot); 44 | } 45 | return exampleDataSnapshotChange(ref); 46 | } 47 | 48 | export function getDatabaseSnapshotCloudEvent( 49 | cloudFunction: CloudFunction>, 50 | cloudEventPartial?: DeepPartial< 51 | database.DatabaseEvent 52 | > 53 | ) { 54 | const { 55 | instance, 56 | firebaseDatabaseHost, 57 | ref, 58 | location, 59 | params, 60 | } = getCommonDatabaseFields(cloudFunction, cloudEventPartial); 61 | 62 | const data = getOrCreateDataSnapshot(cloudEventPartial?.data, ref); 63 | 64 | return { 65 | // Spread common fields 66 | ...getBaseCloudEvent(cloudFunction), 67 | 68 | // Update fields specific to this CloudEvent 69 | data, 70 | 71 | instance, 72 | firebaseDatabaseHost, 73 | ref, 74 | location, 75 | params, 76 | }; 77 | } 78 | 79 | export function getDatabaseChangeSnapshotCloudEvent( 80 | cloudFunction: CloudFunction< 81 | database.DatabaseEvent> 82 | >, 83 | cloudEventPartial?: DeepPartial< 84 | database.DatabaseEvent | ChangeLike> 85 | > 86 | ): database.DatabaseEvent> { 87 | const { 88 | instance, 89 | firebaseDatabaseHost, 90 | ref, 91 | location, 92 | params, 93 | } = getCommonDatabaseFields(cloudFunction, cloudEventPartial); 94 | 95 | const data = getOrCreateDataSnapshotChange(cloudEventPartial?.data, ref); 96 | 97 | return { 98 | // Spread common fields 99 | ...getBaseCloudEvent(cloudFunction), 100 | 101 | // Update fields specific to this CloudEvent 102 | data, 103 | 104 | instance, 105 | firebaseDatabaseHost, 106 | ref, 107 | location, 108 | params, 109 | }; 110 | } 111 | 112 | export function getCommonDatabaseFields( 113 | cloudFunction: CloudFunction< 114 | database.DatabaseEvent< 115 | database.DataSnapshot | Change 116 | > 117 | >, 118 | cloudEventPartial?: DeepPartial< 119 | database.DatabaseEvent< 120 | database.DataSnapshot | Change 121 | > 122 | > 123 | ) { 124 | const instanceOrExpression = 125 | (cloudEventPartial?.instance as string) || 126 | cloudFunction.__endpoint?.eventTrigger?.eventFilterPathPatterns?.instance || 127 | cloudFunction.__endpoint?.eventTrigger?.eventFilters?.instance || 128 | 'instance-1'; 129 | const instance = resolveStringExpression(instanceOrExpression); 130 | const firebaseDatabaseHost = 131 | (cloudEventPartial?.firebaseDatabaseHost as string) || 132 | 'firebaseDatabaseHost'; 133 | const rawRefOrExpression = 134 | (cloudEventPartial?.ref as string) || 135 | cloudFunction?.__endpoint?.eventTrigger?.eventFilterPathPatterns?.ref || 136 | '/foo/bar'; 137 | const rawRef = resolveStringExpression(rawRefOrExpression); 138 | const location = (cloudEventPartial?.location as string) || 'us-central1'; 139 | const params: Record = cloudEventPartial?.params || {}; 140 | const ref = extractRef(rawRef, params); 141 | 142 | return { 143 | instance, 144 | firebaseDatabaseHost, 145 | ref, 146 | location, 147 | params, 148 | }; 149 | } 150 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/database/index.ts: -------------------------------------------------------------------------------- 1 | export { databaseOnValueCreated } from './database-on-value-created'; 2 | export { databaseOnValueDeleted } from './database-on-value-deleted'; 3 | export { databaseOnValueUpdated } from './database-on-value-updated'; 4 | export { databaseOnValueWritten } from './database-on-value-written'; 5 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/eventarc/eventarc-on-custom-event-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction } from 'firebase-functions/v2'; 3 | import { getBaseCloudEvent } from '../helpers'; 4 | 5 | export const eventarcOnCustomEventPublished: MockCloudEventAbstractFactory = { 6 | generateMock( 7 | cloudFunction: CloudFunction>, 8 | cloudEventPartial?: DeepPartial> 9 | ): CloudEvent { 10 | const source = 'eventarc_source'; 11 | const subject = 'eventarc_subject'; 12 | 13 | return { 14 | // Spread common fields 15 | ...getBaseCloudEvent(cloudFunction), 16 | // Spread fields specific to this CloudEvent 17 | data: cloudEventPartial?.data || {}, 18 | source, 19 | subject, 20 | }; 21 | }, 22 | match(_: CloudFunction>): boolean { 23 | return true; 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/firestore-on-document-created-with-auth-context.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction, firestore } from 'firebase-functions/v2'; 3 | import { getEventType } from '../helpers'; 4 | import { QueryDocumentSnapshot } from 'firebase-admin/firestore'; 5 | import { getDocumentSnapshotCloudEventWithAuthContext } from './helpers'; 6 | 7 | export const firestoreOnDocumentCreatedWithAuthContext: MockCloudEventAbstractFactory> = { 10 | generateMock: getDocumentSnapshotCloudEventWithAuthContext, 11 | match(cloudFunction: CloudFunction>): boolean { 12 | return ( 13 | getEventType(cloudFunction) === 14 | 'google.cloud.firestore.document.v1.created.withAuthContext' 15 | ); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/firestore-on-document-created.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction, firestore } from 'firebase-functions/v2'; 3 | import { getEventType } from '../helpers'; 4 | import { QueryDocumentSnapshot } from 'firebase-admin/firestore'; 5 | import { getDocumentSnapshotCloudEvent } from './helpers'; 6 | 7 | export const firestoreOnDocumentCreated: MockCloudEventAbstractFactory> = { 10 | generateMock: getDocumentSnapshotCloudEvent, 11 | match(cloudFunction: CloudFunction>): boolean { 12 | return ( 13 | getEventType(cloudFunction) === 14 | 'google.cloud.firestore.document.v1.created' 15 | ); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/firestore-on-document-deleted-with-auth-context.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction, firestore } from 'firebase-functions/v2'; 3 | import { getEventType } from '../helpers'; 4 | import { QueryDocumentSnapshot } from 'firebase-admin/firestore'; 5 | import { getDocumentSnapshotCloudEventWithAuthContext } from './helpers'; 6 | 7 | export const firestoreOnDocumentDeletedWithAuthContext: MockCloudEventAbstractFactory> = { 10 | generateMock: getDocumentSnapshotCloudEventWithAuthContext, 11 | match(cloudFunction: CloudFunction>): boolean { 12 | return ( 13 | getEventType(cloudFunction) === 14 | 'google.cloud.firestore.document.v1.deleted.withAuthContext' 15 | ); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/firestore-on-document-deleted.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction, firestore } from 'firebase-functions/v2'; 3 | import { getEventType } from '../helpers'; 4 | import { QueryDocumentSnapshot } from 'firebase-admin/firestore'; 5 | import { getDocumentSnapshotCloudEvent } from './helpers'; 6 | 7 | export const firestoreOnDocumentDeleted: MockCloudEventAbstractFactory> = { 10 | generateMock: getDocumentSnapshotCloudEvent, 11 | match(cloudFunction: CloudFunction>): boolean { 12 | return ( 13 | getEventType(cloudFunction) === 14 | 'google.cloud.firestore.document.v1.deleted' 15 | ); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/firestore-on-document-updated-with-auth-context.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { 3 | Change, 4 | CloudEvent, 5 | CloudFunction, 6 | firestore, 7 | } from 'firebase-functions/v2'; 8 | import { getEventType } from '../helpers'; 9 | import { QueryDocumentSnapshot } from 'firebase-admin/firestore'; 10 | import { getDocumentSnapshotChangeCloudEventWithAuthContext } from './helpers'; 11 | 12 | export const firestoreOnDocumentUpdatedWithAuthContext: MockCloudEventAbstractFactory 14 | >> = { 15 | generateMock: getDocumentSnapshotChangeCloudEventWithAuthContext, 16 | match(cloudFunction: CloudFunction>): boolean { 17 | return ( 18 | getEventType(cloudFunction) === 19 | 'google.cloud.firestore.document.v1.updated.withAuthContext' 20 | ); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/firestore-on-document-updated.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { 3 | Change, 4 | CloudEvent, 5 | CloudFunction, 6 | firestore, 7 | } from 'firebase-functions/v2'; 8 | import { getEventType } from '../helpers'; 9 | import { QueryDocumentSnapshot } from 'firebase-admin/firestore'; 10 | import { getDocumentSnapshotChangeCloudEvent } from './helpers'; 11 | 12 | export const firestoreOnDocumentUpdated: MockCloudEventAbstractFactory 14 | >> = { 15 | generateMock: getDocumentSnapshotChangeCloudEvent, 16 | match(cloudFunction: CloudFunction>): boolean { 17 | return ( 18 | getEventType(cloudFunction) === 19 | 'google.cloud.firestore.document.v1.updated' 20 | ); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/firestore-on-document-written-with-auth-context.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { 3 | Change, 4 | CloudEvent, 5 | CloudFunction, 6 | firestore, 7 | } from 'firebase-functions/v2'; 8 | import { getEventType } from '../helpers'; 9 | import { DocumentSnapshot } from 'firebase-admin/firestore'; 10 | import { getDocumentSnapshotChangeCloudEventWithAuthContext } from './helpers'; 11 | 12 | export const firestoreOnDocumentWrittenWithAuthContext: MockCloudEventAbstractFactory 14 | >> = { 15 | generateMock: getDocumentSnapshotChangeCloudEventWithAuthContext, 16 | match(cloudFunction: CloudFunction>): boolean { 17 | return ( 18 | getEventType(cloudFunction) === 19 | 'google.cloud.firestore.document.v1.written.withAuthContext' 20 | ); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/firestore-on-document-written.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../../types'; 2 | import { 3 | Change, 4 | CloudEvent, 5 | CloudFunction, 6 | firestore, 7 | } from 'firebase-functions/v2'; 8 | import { getEventType } from '../helpers'; 9 | import { DocumentSnapshot } from 'firebase-admin/firestore'; 10 | import { getDocumentSnapshotChangeCloudEvent } from './helpers'; 11 | 12 | export const firestoreOnDocumentWritten: MockCloudEventAbstractFactory 14 | >> = { 15 | generateMock: getDocumentSnapshotChangeCloudEvent, 16 | match(cloudFunction: CloudFunction>): boolean { 17 | return ( 18 | getEventType(cloudFunction) === 19 | 'google.cloud.firestore.document.v1.written' 20 | ); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/helpers.ts: -------------------------------------------------------------------------------- 1 | import { DocumentSnapshot } from 'firebase-admin/firestore'; 2 | import { Change, CloudFunction, firestore } from 'firebase-functions/v2'; 3 | import { 4 | exampleDocumentSnapshot, 5 | exampleDocumentSnapshotChange, 6 | makeDocumentSnapshot, 7 | } from '../../../providers/firestore'; 8 | import { DeepPartial } from '../../types'; 9 | import { 10 | extractRef, 11 | getBaseCloudEvent, 12 | resolveStringExpression, 13 | } from '../helpers'; 14 | 15 | type ChangeLike = { 16 | before: DocumentSnapshot | object; 17 | after: DocumentSnapshot | object; 18 | }; 19 | 20 | /** Creates a mock CloudEvent that contains a DocumentSnapshot as its data. */ 21 | export function getDocumentSnapshotCloudEvent( 22 | cloudFunction: CloudFunction>, 23 | cloudEventPartial?: DeepPartial< 24 | firestore.FirestoreEvent 25 | > 26 | ) { 27 | const { 28 | location, 29 | project, 30 | database, 31 | namespace, 32 | document, 33 | params, 34 | } = getFirestoreEventFields(cloudFunction, cloudEventPartial); 35 | const data = getOrCreateDocumentSnapshot(cloudEventPartial?.data, document); 36 | return { 37 | ...getBaseCloudEvent(cloudFunction), 38 | 39 | location, 40 | project, 41 | database, 42 | namespace, 43 | document, 44 | params, 45 | 46 | data, 47 | }; 48 | } 49 | 50 | export function getDocumentSnapshotCloudEventWithAuthContext( 51 | cloudFunction: CloudFunction>, 52 | cloudEventPartial?: DeepPartial< 53 | firestore.FirestoreAuthEvent 54 | > 55 | ) { 56 | const eventWithoutAuthContext = getDocumentSnapshotCloudEvent( 57 | cloudFunction, 58 | cloudEventPartial 59 | ); 60 | const authContext: { authId?: string; authType: firestore.AuthType } = { 61 | authType: 'unknown', 62 | }; 63 | if (cloudEventPartial?.authId) { 64 | authContext.authId = cloudEventPartial.authId; 65 | } 66 | if (cloudEventPartial?.authType) { 67 | authContext.authType = cloudEventPartial.authType; 68 | } 69 | return { 70 | ...eventWithoutAuthContext, 71 | ...authContext, 72 | }; 73 | } 74 | 75 | /** Creates a mock CloudEvent that contains a Change as its data. */ 76 | export function getDocumentSnapshotChangeCloudEvent( 77 | cloudFunction: CloudFunction< 78 | firestore.FirestoreEvent> 79 | >, 80 | cloudEventPartial?: DeepPartial< 81 | firestore.FirestoreEvent | ChangeLike> 82 | > 83 | ) { 84 | const { 85 | location, 86 | project, 87 | database, 88 | namespace, 89 | document, 90 | params, 91 | } = getFirestoreEventFields(cloudFunction, cloudEventPartial); 92 | const data = getOrCreateDocumentSnapshotChange( 93 | cloudEventPartial?.data, 94 | document 95 | ); 96 | return { 97 | ...getBaseCloudEvent(cloudFunction), 98 | 99 | location, 100 | project, 101 | database, 102 | namespace, 103 | document, 104 | params, 105 | 106 | data, 107 | }; 108 | } 109 | 110 | export function getDocumentSnapshotChangeCloudEventWithAuthContext( 111 | cloudFunction: CloudFunction< 112 | firestore.FirestoreAuthEvent> 113 | >, 114 | cloudEventPartial?: DeepPartial< 115 | firestore.FirestoreAuthEvent | ChangeLike> 116 | > 117 | ) { 118 | const eventWithoutAuthContext = getDocumentSnapshotChangeCloudEvent( 119 | cloudFunction, 120 | cloudEventPartial 121 | ); 122 | const authContext: { authId?: string; authType: firestore.AuthType } = { 123 | authType: 'unknown', 124 | }; 125 | if (cloudEventPartial?.authId) { 126 | authContext.authId = cloudEventPartial.authId; 127 | } 128 | if (cloudEventPartial?.authType) { 129 | authContext.authType = cloudEventPartial.authType; 130 | } 131 | 132 | return { 133 | ...eventWithoutAuthContext, 134 | ...authContext, 135 | }; 136 | } 137 | 138 | /** Finds or provides reasonable defaults for mock FirestoreEvent data. */ 139 | function getFirestoreEventFields( 140 | cloudFunction: CloudFunction< 141 | firestore.FirestoreEvent> 142 | >, 143 | cloudEventPartial?: DeepPartial< 144 | firestore.FirestoreEvent> 145 | > 146 | ) { 147 | const location = cloudEventPartial?.location || 'us-central1'; 148 | 149 | const project = 150 | cloudEventPartial?.project || process.env.GCLOUD_PROJECT || 'testproject'; 151 | 152 | const databaseOrExpression = 153 | cloudEventPartial?.database || 154 | cloudFunction?.__endpoint?.eventTrigger?.eventFilters?.database || 155 | '(default)'; 156 | const database = resolveStringExpression(databaseOrExpression); 157 | 158 | const namespaceOrExpression = 159 | cloudEventPartial?.namespace || 160 | cloudFunction?.__endpoint?.eventTrigger?.eventFilters?.namespace || 161 | '(default)'; 162 | const namespace = resolveStringExpression(namespaceOrExpression); 163 | 164 | const params = cloudEventPartial?.params || {}; 165 | 166 | const documentOrExpression = 167 | cloudEventPartial?.document || 168 | cloudFunction?.__endpoint?.eventTrigger?.eventFilters?.document || 169 | cloudFunction?.__endpoint?.eventTrigger?.eventFilterPathPatterns 170 | ?.document || 171 | '/foo/bar'; 172 | const documentRaw = resolveStringExpression(documentOrExpression); 173 | const document = extractRef(documentRaw, params); 174 | 175 | return { 176 | location, 177 | project, 178 | database, 179 | namespace, 180 | document, 181 | params, 182 | }; 183 | } 184 | 185 | /** Make a DocumentSnapshot from the user-provided partial data. */ 186 | function getOrCreateDocumentSnapshot( 187 | data: DocumentSnapshot | object, 188 | ref: string 189 | ) { 190 | if (data instanceof DocumentSnapshot) { 191 | return data; 192 | } 193 | if (data instanceof Object) { 194 | return makeDocumentSnapshot(data, ref); 195 | } 196 | return exampleDocumentSnapshot(); 197 | } 198 | 199 | /** Make a DocumentSnapshotChange from the user-provided partial data. */ 200 | function getOrCreateDocumentSnapshotChange( 201 | data: DeepPartial | ChangeLike>, 202 | ref: string 203 | ) { 204 | if (data instanceof Change) { 205 | return data; 206 | } 207 | // If only the "before" or "after" is specified (to simulate a 208 | // Created or Deleted event for the onWritten trigger), then we 209 | // include the user's before/after object in the mock event and 210 | // use an example snapshot for the other. 211 | if (data?.before || data?.after) { 212 | const beforeSnapshot = getOrCreateDocumentSnapshot(data.before, ref); 213 | const afterSnapshot = getOrCreateDocumentSnapshot(data.after, ref); 214 | return new Change(beforeSnapshot, afterSnapshot); 215 | } 216 | return exampleDocumentSnapshotChange(); 217 | } 218 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/firestore/index.ts: -------------------------------------------------------------------------------- 1 | export { firestoreOnDocumentCreated } from './firestore-on-document-created'; 2 | export { firestoreOnDocumentDeleted } from './firestore-on-document-deleted'; 3 | export { firestoreOnDocumentUpdated } from './firestore-on-document-updated'; 4 | export { firestoreOnDocumentWritten } from './firestore-on-document-written'; 5 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as v1 from 'firebase-functions/v1'; 2 | import * as v2 from 'firebase-functions/v2'; 3 | import { Expression } from 'firebase-functions/params'; 4 | 5 | export const APP_ID = '__APP_ID__'; 6 | export const PROJECT_ID = '42'; 7 | export const FILENAME = 'file_name'; 8 | 9 | type CloudFunction = v1.CloudFunction | v2.CloudFunction; 10 | 11 | export function getEventType(cloudFunction: CloudFunction): string { 12 | return cloudFunction?.__endpoint?.eventTrigger?.eventType || ''; 13 | } 14 | 15 | export function getEventFilters( 16 | cloudFunction: CloudFunction 17 | ): Record> { 18 | return cloudFunction?.__endpoint?.eventTrigger?.eventFilters || {}; 19 | } 20 | 21 | export function getBaseCloudEvent>( 22 | cloudFunction: v2.CloudFunction 23 | ): EventType { 24 | return { 25 | specversion: '1.0', 26 | id: makeEventId(), 27 | data: undefined, 28 | source: '', // Required field that will get overridden by Provider-specific MockCloudEventPartials 29 | type: getEventType(cloudFunction), 30 | time: new Date().toISOString(), 31 | } as EventType; 32 | } 33 | 34 | export function resolveStringExpression( 35 | stringOrExpression: string | Expression 36 | ) { 37 | if (typeof stringOrExpression === 'string') { 38 | return stringOrExpression; 39 | } 40 | return stringOrExpression?.value(); 41 | } 42 | 43 | function makeEventId(): string { 44 | return ( 45 | Math.random() 46 | .toString(36) 47 | .substring(2, 15) + 48 | Math.random() 49 | .toString(36) 50 | .substring(2, 15) 51 | ); 52 | } 53 | 54 | /** Resolves param values and inserts them into the provided ref string. */ 55 | export function extractRef(rawRef: string, params: Record) { 56 | const refSegments = rawRef.split('/'); 57 | 58 | return refSegments 59 | .map((segment) => { 60 | if (segment.startsWith('{') && segment.endsWith('}')) { 61 | const param = segment 62 | .slice(1, -1) 63 | .replace('=**', '') 64 | .replace('=*', ''); 65 | return params[param] || 'undefined'; 66 | } 67 | return segment; 68 | }) 69 | .join('/'); 70 | } 71 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/partials.ts: -------------------------------------------------------------------------------- 1 | import { MockCloudEventAbstractFactory } from '../types'; 2 | import { alertsOnAlertPublished } from './alerts/alerts-on-alert-published'; 3 | import { alertsCrashlyticsOnNewAnrIssuePublished } from './alerts/crashlytics-on-new-anr-issue-published'; 4 | import { alertsCrashlyticsOnNewFatalIssuePublished } from './alerts/crashlytics-on-new-fatal-issue-published'; 5 | import { alertsCrashlyticsOnNewNonfatalIssuePublished } from './alerts/crashlytics-on-new-nonfatal-issue-published'; 6 | import { alertsCrashlyticsOnRegressionAlertPublished } from './alerts/crashlytics-on-regression-alert-published'; 7 | import { alertsCrashlyticsOnStabilityDigestPublished } from './alerts/crashlytics-on-stability-digest-published'; 8 | import { alertsCrashlyticsOnVelocityAlertPublished } from './alerts/crashlytics-on-velocity-alert-published'; 9 | import { alertsAppDistributionOnNewTesterIosDevicePublished } from './alerts/app-distribution-on-new-tester-ios-device-published'; 10 | import { alertsBillingOnPlanAutomatedUpdatePublished } from './alerts/billing-on-plan-automated-update-published'; 11 | import { alertsBillingOnPlanUpdatePublished } from './alerts/billing-on-plan-update-published'; 12 | import { performanceThresholdOnThresholdAlertPublished } from './alerts/performance-on-threshold-alert-published'; 13 | import { eventarcOnCustomEventPublished } from './eventarc/eventarc-on-custom-event-published'; 14 | import { pubsubOnMessagePublished } from './pubsub/pubsub-on-message-published'; 15 | import { 16 | databaseOnValueCreated, 17 | databaseOnValueDeleted, 18 | databaseOnValueUpdated, 19 | databaseOnValueWritten, 20 | } from './database'; 21 | import { 22 | firestoreOnDocumentCreated, 23 | firestoreOnDocumentDeleted, 24 | firestoreOnDocumentUpdated, 25 | firestoreOnDocumentWritten, 26 | } from './firestore'; 27 | import { storageV1 } from './storage'; 28 | import { remoteConfigOnConfigUpdated } from './remoteconfig/remote-config-on-config-updated'; 29 | import { testLabOnTestMatrixCompleted } from './testlab/test-lab-on-test-matrix-completed'; 30 | import { firestoreOnDocumentCreatedWithAuthContext } from './firestore/firestore-on-document-created-with-auth-context'; 31 | import { firestoreOnDocumentDeletedWithAuthContext } from './firestore/firestore-on-document-deleted-with-auth-context'; 32 | import { firestoreOnDocumentUpdatedWithAuthContext } from './firestore/firestore-on-document-updated-with-auth-context'; 33 | import { firestoreOnDocumentWrittenWithAuthContext } from './firestore/firestore-on-document-written-with-auth-context'; 34 | 35 | /** 36 | * Note: Ordering matters. Some MockEventPartials will match more generally 37 | * (eg {@link alertsOnAlertPublished}). In addition, 38 | * {@link eventarcOnCustomEventPublished} acts as a catch-all. 39 | */ 40 | export const LIST_OF_MOCK_CLOUD_EVENT_PARTIALS: Array> = [ 43 | alertsCrashlyticsOnNewAnrIssuePublished, 44 | alertsCrashlyticsOnNewFatalIssuePublished, 45 | alertsCrashlyticsOnNewNonfatalIssuePublished, 46 | alertsCrashlyticsOnRegressionAlertPublished, 47 | alertsCrashlyticsOnStabilityDigestPublished, 48 | alertsCrashlyticsOnVelocityAlertPublished, 49 | alertsAppDistributionOnNewTesterIosDevicePublished, 50 | alertsBillingOnPlanAutomatedUpdatePublished, 51 | alertsBillingOnPlanUpdatePublished, 52 | performanceThresholdOnThresholdAlertPublished, 53 | alertsOnAlertPublished, // Note: alert.onAlertPublished matching is more generic 54 | remoteConfigOnConfigUpdated, 55 | testLabOnTestMatrixCompleted, 56 | storageV1, 57 | pubsubOnMessagePublished, 58 | databaseOnValueCreated, 59 | databaseOnValueDeleted, 60 | databaseOnValueUpdated, 61 | databaseOnValueWritten, 62 | firestoreOnDocumentCreated, 63 | firestoreOnDocumentDeleted, 64 | firestoreOnDocumentUpdated, 65 | firestoreOnDocumentWritten, 66 | firestoreOnDocumentCreatedWithAuthContext, 67 | firestoreOnDocumentDeletedWithAuthContext, 68 | firestoreOnDocumentUpdatedWithAuthContext, 69 | firestoreOnDocumentWrittenWithAuthContext, 70 | 71 | // CustomEventPublished must be called last 72 | eventarcOnCustomEventPublished, 73 | ]; 74 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/pubsub/pubsub-on-message-published.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudEvent, CloudFunction, pubsub } from 'firebase-functions/v2'; 3 | import { 4 | getBaseCloudEvent, 5 | getEventFilters, 6 | getEventType, 7 | PROJECT_ID, 8 | } from '../helpers'; 9 | 10 | export const pubsubOnMessagePublished: MockCloudEventAbstractFactory> = { 13 | generateMock( 14 | cloudFunction: CloudFunction>, 15 | cloudEventPartial?: DeepPartial> 16 | ): CloudEvent { 17 | const topicId = getEventFilters(cloudFunction)?.topic || ''; 18 | const source = `//pubsub.googleapis.com/projects/${PROJECT_ID}/topics/${topicId}`; 19 | const subscription = `projects/${PROJECT_ID}/subscriptions/pubsubexample-1`; 20 | 21 | // Used if no data.message.json is provided by the partial; 22 | const dataMessageJsonDefault = { hello: 'world' }; 23 | const dataMessageAttributesDefault = { 24 | 'sample-attribute': 'I am an attribute', 25 | }; 26 | 27 | const dataMessageJson = 28 | cloudEventPartial?.data?.message?.json || dataMessageJsonDefault; 29 | 30 | // We should respect if the user provides their own message.data. 31 | const dataMessageData = 32 | cloudEventPartial?.data?.message?.data || 33 | Buffer.from(JSON.stringify(dataMessageJson)).toString('base64'); 34 | 35 | // TODO - consider warning the user if their data does not match the json they provide 36 | 37 | const messageData = { 38 | data: dataMessageData, 39 | messageId: cloudEventPartial?.data?.message?.messageId || 'message_id', 40 | attributes: 41 | cloudEventPartial?.data?.message?.attributes || 42 | dataMessageAttributesDefault, 43 | }; 44 | const message = new pubsub.Message(messageData); 45 | 46 | return { 47 | // Spread common fields 48 | ...getBaseCloudEvent(cloudFunction), 49 | // Spread fields specific to this CloudEvent 50 | source, 51 | data: { 52 | /** 53 | * Note: Its very important we return the JSON representation of the message here. Without it, 54 | * ts-merge blows away `data.message`. 55 | */ 56 | message: message.toJSON(), 57 | subscription, 58 | }, 59 | }; 60 | }, 61 | match(cloudFunction: CloudFunction>): boolean { 62 | return ( 63 | getEventType(cloudFunction) === 64 | 'google.cloud.pubsub.topic.v1.messagePublished' 65 | ); 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/remoteconfig/remote-config-on-config-updated.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction, CloudEvent } from 'firebase-functions/v2'; 3 | import { ConfigUpdateData } from 'firebase-functions/v2/remoteConfig'; 4 | import { getBaseCloudEvent, getEventType, PROJECT_ID } from '../helpers'; 5 | 6 | export const remoteConfigOnConfigUpdated: MockCloudEventAbstractFactory> = { 9 | generateMock( 10 | cloudFunction: CloudFunction> 11 | ): CloudEvent { 12 | const source = `//firebaseremoteconfig.googleapis.com/projects/${PROJECT_ID}`; 13 | return { 14 | ...getBaseCloudEvent(cloudFunction), 15 | source, 16 | data: getConfigUpdateData(), 17 | }; 18 | }, 19 | match(cloudFunction: CloudFunction>): boolean { 20 | return ( 21 | getEventType(cloudFunction) === 22 | 'google.firebase.remoteconfig.remoteConfig.v1.updated' 23 | ); 24 | }, 25 | }; 26 | 27 | function getConfigUpdateData(): ConfigUpdateData { 28 | const now = new Date().toISOString(); 29 | return { 30 | versionNumber: 2, 31 | updateTime: now, 32 | updateUser: { 33 | name: 'testuser', 34 | email: 'test@example.com', 35 | imageUrl: 'test.com/img-url', 36 | }, 37 | description: 'config update test', 38 | updateOrigin: 'REMOTE_CONFIG_UPDATE_ORIGIN_UNSPECIFIED', 39 | updateType: 'REMOTE_CONFIG_UPDATE_TYPE_UNSPECIFIED', 40 | rollbackSource: 0, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/storage/index.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction, CloudEvent } from 'firebase-functions/v2'; 3 | import { StorageEvent } from 'firebase-functions/v2/storage'; 4 | import { 5 | FILENAME, 6 | resolveStringExpression, 7 | getBaseCloudEvent, 8 | getEventFilters, 9 | getEventType, 10 | } from '../helpers'; 11 | import { getStorageObjectData } from './storage-data'; 12 | 13 | export const storageV1: MockCloudEventAbstractFactory = { 14 | generateMock( 15 | cloudFunction: CloudFunction, 16 | cloudEventPartial?: DeepPartial 17 | ): StorageEvent { 18 | const bucketOrExpression = 19 | cloudEventPartial?.bucket || 20 | getEventFilters(cloudFunction)?.bucket || 21 | 'bucket_name'; 22 | const bucket = resolveStringExpression(bucketOrExpression); 23 | const source = 24 | cloudEventPartial?.source || 25 | `//storage.googleapis.com/projects/_/buckets/${bucket}`; 26 | const subject = cloudEventPartial?.subject || `objects/${FILENAME}`; 27 | 28 | return { 29 | // Spread common fields 30 | ...getBaseCloudEvent(cloudFunction), 31 | // Spread fields specific to this CloudEvent 32 | bucket, 33 | source, 34 | subject, 35 | data: getStorageObjectData(bucket, FILENAME, 1), 36 | }; 37 | }, 38 | match(cloudFunction: CloudFunction>): boolean { 39 | return getEventType(cloudFunction).startsWith( 40 | 'google.cloud.storage.object.v1' 41 | ); 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/storage/storage-data.ts: -------------------------------------------------------------------------------- 1 | import { storage } from 'firebase-functions/v2'; 2 | import { FILENAME } from '../helpers'; 3 | 4 | /** Storage Data */ 5 | export function getStorageObjectData( 6 | bucket: string, 7 | filename: string, 8 | generation: number 9 | ): storage.StorageObjectData { 10 | const now = new Date().toISOString(); 11 | return { 12 | metageneration: 1, 13 | metadata: { 14 | firebaseStorageDownloadTokens: '00000000-0000-0000-0000-000000000000', 15 | }, 16 | kind: 'storage#object', 17 | mediaLink: `https://www.googleapis.com/download/storage/v1/b/${bucket}/o/${FILENAME}?generation=${generation}&alt=media`, 18 | etag: 'xxxxxxxxx/yyyyy=', 19 | timeStorageClassUpdated: now, 20 | generation, 21 | md5Hash: 'E9LIfVl7pcVu3/moXc743w==', 22 | crc32c: 'qqqqqq==', 23 | selfLink: `https://www.googleapis.com/storage/v1/b/${bucket}/o/${FILENAME}`, 24 | name: FILENAME, 25 | storageClass: 'REGIONAL', 26 | size: 42, 27 | updated: now, 28 | contentDisposition: `inline; filename*=utf-8''${FILENAME}`, 29 | contentType: 'image/gif', 30 | timeCreated: now, 31 | id: `${bucket}/${FILENAME}/${generation}`, 32 | bucket, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/cloudevent/mocks/testlab/test-lab-on-test-matrix-completed.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, MockCloudEventAbstractFactory } from '../../types'; 2 | import { CloudFunction, CloudEvent } from 'firebase-functions/v2'; 3 | import { TestMatrixCompletedData } from 'firebase-functions/v2/testLab'; 4 | import { getBaseCloudEvent, getEventType, PROJECT_ID } from '../helpers'; 5 | 6 | export const testLabOnTestMatrixCompleted: MockCloudEventAbstractFactory> = { 9 | generateMock( 10 | cloudFunction: CloudFunction> 11 | ): CloudEvent { 12 | const source = `//firebasetestlab.googleapis.com/projects/${PROJECT_ID}`; 13 | return { 14 | ...getBaseCloudEvent(cloudFunction), 15 | source, 16 | data: getTestMatrixCompletedData(), 17 | }; 18 | }, 19 | match(cloudFunction: CloudFunction>) { 20 | return ( 21 | getEventType(cloudFunction) === 22 | 'google.firebase.testlab.testMatrix.v1.completed' 23 | ); 24 | }, 25 | }; 26 | 27 | function getTestMatrixCompletedData(): TestMatrixCompletedData { 28 | const now = new Date().toISOString(); 29 | return { 30 | createTime: now, 31 | state: 'TEST_STATE_UNSPECIFIED', 32 | invalidMatrixDetails: '', 33 | outcomeSummary: 'OUTCOME_SUMMARY_UNSPECIFIED', 34 | resultStorage: { 35 | toolResultsHistory: `projects/${PROJECT_ID}/histories/1234`, 36 | toolResultsExecution: `projects/${PROJECT_ID}/histories/1234/executions/5678`, 37 | resultsUri: 'console.firebase.google.com/test/results', 38 | gcsPath: 'gs://bucket/path/to/test', 39 | }, 40 | clientInfo: { 41 | client: 'gcloud', 42 | details: {}, 43 | }, 44 | testMatrixId: '1234', 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/cloudevent/types.ts: -------------------------------------------------------------------------------- 1 | import { CloudEvent, CloudFunction } from 'firebase-functions/v2'; 2 | 3 | export type DeepPartial = { 4 | [Key in keyof T]?: T[Key] extends object ? DeepPartial : T[Key]; 5 | }; 6 | type MockCloudEventFunction> = ( 7 | cloudFunction: CloudFunction, 8 | cloudEventPartial?: DeepPartial 9 | ) => EventType; 10 | type MockCloudEventMatchFunction> = ( 11 | cloudFunction: CloudFunction 12 | ) => boolean; 13 | 14 | export interface MockCloudEventAbstractFactory< 15 | EventType extends CloudEvent 16 | > { 17 | generateMock: MockCloudEventFunction; 18 | match: MockCloudEventMatchFunction; 19 | } 20 | -------------------------------------------------------------------------------- /src/features.ts: -------------------------------------------------------------------------------- 1 | import { makeChange, wrap, mockConfig } from './main'; 2 | import * as analytics from './providers/analytics'; 3 | import * as auth from './providers/auth'; 4 | import * as database from './providers/database'; 5 | import * as firestore from './providers/firestore'; 6 | import * as pubsub from './providers/pubsub'; 7 | import * as storage from './providers/storage'; 8 | import { FirebaseFunctionsTest } from './lifecycle'; 9 | 10 | export interface LazyFeatures { 11 | mockConfig: typeof mockConfig; 12 | wrap: typeof wrap; 13 | makeChange: typeof makeChange; 14 | analytics: typeof analytics; 15 | auth: typeof auth; 16 | database: typeof database; 17 | firestore: typeof firestore; 18 | pubsub: typeof pubsub; 19 | storage: typeof storage; 20 | } 21 | 22 | export const features: LazyFeatures = { 23 | mockConfig, 24 | wrap, 25 | makeChange, 26 | analytics, 27 | auth, 28 | database, 29 | firestore, 30 | pubsub, 31 | storage, 32 | }; 33 | 34 | export interface FeaturesList extends LazyFeatures { 35 | cleanup: InstanceType['cleanup']; 36 | } 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { AppOptions } from 'firebase-admin'; 24 | import { merge } from 'lodash'; 25 | 26 | import { FirebaseFunctionsTest } from './lifecycle'; 27 | import { FeaturesList } from './features'; 28 | 29 | export = ( 30 | firebaseConfig?: AppOptions, 31 | pathToServiceAccountKey?: string 32 | ): FeaturesList => { 33 | const test = new FirebaseFunctionsTest(); 34 | test.init(firebaseConfig, pathToServiceAccountKey); 35 | // Ensure other files get loaded after init function, since they load `firebase-functions` 36 | // which will issue warning if process.env.FIREBASE_CONFIG is not yet set. 37 | let features = require('./features').features; 38 | features = merge({}, features, { 39 | cleanup: () => test.cleanup(), 40 | }); 41 | return features; 42 | }; 43 | -------------------------------------------------------------------------------- /src/lifecycle.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { isEmpty } from 'lodash'; 24 | import { AppOptions } from 'firebase-admin'; 25 | import { forEach } from 'lodash'; 26 | 27 | import { testApp } from './app'; 28 | 29 | export class FirebaseFunctionsTest { 30 | private _oldEnv: { [key: string]: string }; 31 | 32 | constructor() { 33 | this._oldEnv = {}; 34 | } 35 | 36 | /** Initialize the SDK. */ 37 | init( 38 | /** Firebase config values for initializing a Firebase app for your test code to 39 | * interact with (e.g. making database writes). It is recommended that you use 40 | * a project that is specifically for testing. If omitted, mock config values will 41 | * be used and your tests will not interact with a real Firebase app, and all Firebase 42 | * methods need to be stubbed, otherwise they will fail. 43 | */ 44 | firebaseConfig?: AppOptions, 45 | /** Path to a service account key file to be used when initializing the Firebase app. */ 46 | pathToServiceAccountKey?: string 47 | ) { 48 | this._oldEnv = { 49 | FIREBASE_CONFIG: process.env.FIREBASE_CONFIG, 50 | GOOGLE_APPLICATION_CREDENTIALS: 51 | process.env.GOOGLE_APPLICATION_CREDENTIALS, 52 | GCLOUD_PROJECT: process.env.GCLOUD_PROJECT, 53 | CLOUD_RUNTIME_CONFIG: process.env.CLOUD_RUNTIME_CONFIG, 54 | }; 55 | 56 | if (isEmpty(firebaseConfig)) { 57 | process.env.FIREBASE_CONFIG = JSON.stringify({ 58 | databaseURL: 'https://not-a-project.firebaseio.com', 59 | storageBucket: 'not-a-project.appspot.com', 60 | projectId: 'not-a-project', 61 | }); 62 | } else { 63 | process.env.FIREBASE_CONFIG = JSON.stringify(firebaseConfig); 64 | if (pathToServiceAccountKey) { 65 | process.env.GOOGLE_APPLICATION_CREDENTIALS = pathToServiceAccountKey; 66 | } 67 | } 68 | process.env.GCLOUD_PROJECT = JSON.parse( 69 | process.env.FIREBASE_CONFIG 70 | ).projectId; 71 | } 72 | 73 | /** Complete clean up tasks. */ 74 | cleanup() { 75 | forEach(this._oldEnv, (val, varName) => { 76 | if (typeof val !== 'undefined') { 77 | process.env[varName] = val; 78 | } else { 79 | delete process.env[varName]; 80 | } 81 | }); 82 | testApp().deleteApp(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { 24 | CloudFunction as CloudFunctionV1, 25 | HttpsFunction, 26 | Runnable, 27 | } from 'firebase-functions/v1'; 28 | 29 | import { 30 | CloudFunction as CloudFunctionV2, 31 | CloudEvent, 32 | } from 'firebase-functions/v2'; 33 | 34 | import { 35 | CallableFunction, 36 | HttpsFunction as HttpsFunctionV2, 37 | } from 'firebase-functions/v2/https'; 38 | 39 | import { wrapV1, WrappedFunction, WrappedScheduledFunction } from './v1'; 40 | 41 | import { wrapV2, WrappedV2Function, WrappedV2CallableFunction } from './v2'; 42 | 43 | type HttpsFunctionOrCloudFunctionV1 = U extends HttpsFunction & 44 | Runnable 45 | ? HttpsFunction & Runnable 46 | : CloudFunctionV1; 47 | 48 | export { mockSecretManager } from './secretManager'; 49 | 50 | // Re-exporting V1 (to reduce breakage) 51 | export { 52 | ContextOptions, 53 | EventContextOptions, 54 | WrappedFunction, 55 | WrappedScheduledFunction, 56 | CallableContextOptions, 57 | makeChange, 58 | mockConfig, 59 | } from './v1'; 60 | 61 | // V2 Exports 62 | export { WrappedV2Function } from './v2'; 63 | 64 | export function wrap( 65 | cloudFunction: HttpsFunction & Runnable 66 | ): WrappedFunction>; 67 | export function wrap( 68 | cloudFunction: CallableFunction 69 | ): WrappedV2CallableFunction; 70 | export function wrap( 71 | cloudFunction: CloudFunctionV1 72 | ): WrappedScheduledFunction | WrappedFunction; 73 | export function wrap>( 74 | cloudFunction: CloudFunctionV2 75 | ): WrappedV2Function; 76 | export function wrap>( 77 | cloudFunction: CloudFunctionV1 | CloudFunctionV2 | HttpsFunctionV2 78 | ): 79 | | WrappedScheduledFunction 80 | | WrappedFunction 81 | | WrappedV2Function 82 | | WrappedV2CallableFunction { 83 | if (isV2CloudFunction(cloudFunction)) { 84 | return wrapV2(cloudFunction as CloudFunctionV2); 85 | } 86 | return wrapV1( 87 | cloudFunction as HttpsFunctionOrCloudFunctionV1 88 | ); 89 | } 90 | 91 | /** 92 | * The key differences between V1 and V2 CloudFunctions are: 93 | *
    94 | *
  • V1 CloudFunction is sometimes a binary function 95 | *
  • V2 CloudFunction is always a unary function 96 | *
  • V1 CloudFunction.run is always a binary function 97 | *
  • V2 CloudFunction.run is always a unary function 98 | * @return True iff the CloudFunction is a V2 function. 99 | */ 100 | function isV2CloudFunction>( 101 | cloudFunction: any 102 | ): cloudFunction is CloudFunctionV2 { 103 | return cloudFunction.length === 1 && cloudFunction?.run?.length === 1; 104 | } 105 | -------------------------------------------------------------------------------- /src/providers/analytics.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { analytics } from 'firebase-functions/v1'; 24 | 25 | /** Create an AnalyticsEvent */ 26 | export function makeAnalyticsEvent( 27 | /** Fields of AnalyticsEvent that you'd like to specify. */ 28 | fields: { [key: string]: string } 29 | ): analytics.AnalyticsEvent { 30 | const template = { 31 | reportingDate: '', 32 | name: '', 33 | params: {}, 34 | logTime: '', 35 | }; 36 | return Object.assign(template, fields); 37 | } 38 | 39 | /** Fetch an example AnalyticsEvent already populated with data. */ 40 | export function exampleAnalyticsEvent(): analytics.AnalyticsEvent { 41 | return { 42 | reportingDate: '20170202', 43 | name: 'Loaded_In_Background', 44 | params: { 45 | build: '1350', 46 | calls_remaining: 10, 47 | fraction_calls_dropped: 0.0123456, 48 | average_call_rating: 4.5, 49 | }, 50 | logTime: '2017-02-02T23:06:26.124Z', 51 | previousLogTime: '2017-02-02T23:01:19.797Z', 52 | valueInUSD: 1234.5, 53 | user: { 54 | userId: 'abcdefghijklmnop!', 55 | appInfo: { 56 | appId: 'com.appName', 57 | appInstanceId: 'E3C9939401814B9B954725A740B8C7BC', 58 | appPlatform: 'IOS', 59 | appStore: 'iTunes', 60 | appVersion: '5.2.0', 61 | }, 62 | bundleInfo: { 63 | bundleSequenceId: 6034, 64 | serverTimestampOffset: 371, 65 | }, 66 | deviceInfo: { 67 | deviceCategory: 'mobile', 68 | deviceModel: 'iPhone7,2', 69 | deviceTimeZoneOffsetSeconds: -21600, 70 | mobileBrandName: 'Apple', 71 | mobileMarketingName: 'iPhone 6', 72 | mobileModelName: 'iPhone 6', 73 | platformVersion: '10.2.1', 74 | userDefaultLanguage: 'en-us', 75 | deviceId: '599F9C00-92DC-4B5C-9464-7971F01F8370', 76 | resettableDeviceId: '599F9C00-92DC-4B5C-9464-7971F01F8370', 77 | limitedAdTracking: true, 78 | }, 79 | firstOpenTime: '2016-04-28T15:00:35.819Z', 80 | geoInfo: { 81 | city: 'Plano', 82 | continent: '021', 83 | country: 'United States', 84 | region: 'Texas', 85 | }, 86 | userProperties: { 87 | build: { 88 | setTime: '2017-02-02T23:06:26.090Z', 89 | value: '1350', 90 | }, 91 | calls_remaining: { 92 | setTime: '2017-02-02T23:06:26.094Z', 93 | value: '10', 94 | }, 95 | version: { 96 | setTime: '2017-02-02T23:06:26.085Z', 97 | value: '5.2.0', 98 | }, 99 | }, 100 | }, 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /src/providers/auth.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { auth } from 'firebase-functions/v1'; 24 | 25 | /** Create a UserRecord. */ 26 | export function makeUserRecord( 27 | /** Fields of AuthRecord that you'd like to specify. */ 28 | fields: { [key: string]: string | boolean } 29 | ): auth.UserRecord { 30 | return auth.userRecordConstructor(Object.assign({ uid: '' }, fields)); 31 | } 32 | 33 | /** Fetch an example UserRecord already populated with data. */ 34 | export function exampleUserRecord(): auth.UserRecord { 35 | return auth.userRecordConstructor({ 36 | email: 'user@gmail.com', 37 | metadata: { 38 | creationTime: '2018-03-13T01:24:48Z', 39 | lastSignInTime: '2018-04-03T03:52:48Z', 40 | }, 41 | uid: 'SQol8dFfyMapsQtRD4JgZdC5r1G2', 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/providers/database.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { database, Change } from 'firebase-functions/v1'; 24 | import { app } from 'firebase-admin'; 25 | 26 | import { testApp } from '../app'; 27 | 28 | /** Create a DataSnapshot. */ 29 | export function makeDataSnapshot( 30 | /** Value of data for the snapshot. */ 31 | val: string | number | boolean | any[] | object | null, 32 | /** Full path of the reference (e.g. 'users/alovelace'). */ 33 | refPath: string, 34 | /** The Firebase app that the database belongs to. 35 | * The databaseURL supplied when initializing the app will be used for creating this snapshot. 36 | * You do not need to supply this parameter if you supplied Firebase config values when initializing 37 | * firebase-functions-test. 38 | */ 39 | firebaseApp?: app.App, 40 | /** 41 | * The RTDB instance to use when creating snapshot. This will override the `firebaseApp` parameter. 42 | * If omitted the default RTDB instance is used. 43 | */ 44 | instance?: string 45 | ): database.DataSnapshot { 46 | return new database.DataSnapshot( 47 | val, 48 | refPath, 49 | firebaseApp || testApp().getApp(), 50 | instance 51 | ); 52 | } 53 | 54 | /** Fetch an example data snapshot already populated with data. Can be passed into a wrapped 55 | * database onCreate or onDelete function. 56 | */ 57 | export function exampleDataSnapshot( 58 | refPath = 'messages/1234' 59 | ): database.DataSnapshot { 60 | return makeDataSnapshot({ foo: 'bar ' }, refPath); 61 | } 62 | 63 | /** Fetch an example Change object of data snapshots already populated with data. 64 | * Can be passed into a wrapped database onUpdate or onWrite function. 65 | */ 66 | export function exampleDataSnapshotChange( 67 | beforeRefPath = 'messages/1234', 68 | afterRefPath = 'messages/1234' 69 | ): Change { 70 | return Change.fromObjects( 71 | makeDataSnapshot({ foo: 'faz' }, beforeRefPath), 72 | makeDataSnapshot({ foo: 'bar' }, afterRefPath) 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/providers/firestore.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { Change } from 'firebase-functions/v1'; 24 | import { firestore, app } from 'firebase-admin'; 25 | import { has, get, isEmpty, isPlainObject, mapValues } from 'lodash'; 26 | import { inspect } from 'util'; 27 | 28 | import { testApp } from '../app'; 29 | 30 | import * as http from 'http'; 31 | import { 32 | DocumentSnapshot, 33 | QueryDocumentSnapshot, 34 | } from 'firebase-admin/firestore'; 35 | 36 | function dateToTimestampProto( 37 | timeString?: string 38 | ): { seconds: number; nanos: number } | undefined { 39 | if (typeof timeString === 'undefined') { 40 | return; 41 | } 42 | const date = new Date(timeString); 43 | const seconds = Math.floor(date.getTime() / 1000); 44 | let nanos = 0; 45 | if (timeString.length > 20) { 46 | const nanoString = timeString.substring(20, timeString.length - 1); 47 | const trailingZeroes = 9 - nanoString.length; 48 | nanos = parseInt(nanoString, 10) * Math.pow(10, trailingZeroes); 49 | } 50 | return { seconds, nanos }; 51 | } 52 | 53 | /** Optional parameters for creating a DocumentSnapshot. */ 54 | export interface DocumentSnapshotOptions { 55 | /** ISO timestamp string for the snapshot was read, default is current time. */ 56 | readTime?: string; 57 | /** ISO timestamp string for the snapshot was created, default is current time. */ 58 | createTime?: string; 59 | /** ISO timestamp string for the snapshot was last updated, default is current time. */ 60 | updateTime?: string; 61 | /** The Firebase app that the Firestore database belongs to. You do not need to supply 62 | * this parameter if you supplied Firebase config values when initializing firebase-functions-test. 63 | */ 64 | firebaseApp?: app.App; 65 | } 66 | 67 | /** Create a DocumentSnapshot. */ 68 | export function makeDocumentSnapshot( 69 | /** Key-value pairs representing data in the document, pass in `{}` to mock the snapshot of 70 | * a document that doesn't exist. 71 | */ 72 | data: { [key: string]: any }, 73 | /** Full path of the reference (e.g. 'users/alovelace') */ 74 | refPath: string, 75 | options?: DocumentSnapshotOptions 76 | ) { 77 | let firestoreService; 78 | let project; 79 | if (has(options, 'app')) { 80 | firestoreService = firestore(options.firebaseApp); 81 | project = get(options, 'app.options.projectId'); 82 | } else { 83 | firestoreService = firestore(testApp().getApp()); 84 | project = process.env.GCLOUD_PROJECT; 85 | } 86 | 87 | const resource = `projects/${project}/databases/(default)/documents/${refPath}`; 88 | const proto = isEmpty(data) 89 | ? resource 90 | : { 91 | fields: objectToValueProto(data), 92 | createTime: dateToTimestampProto( 93 | get(options, 'createTime', new Date().toISOString()) 94 | ), 95 | updateTime: dateToTimestampProto( 96 | get(options, 'updateTime', new Date().toISOString()) 97 | ), 98 | name: resource, 99 | }; 100 | 101 | const readTimeProto = dateToTimestampProto( 102 | get(options, 'readTime') || new Date().toISOString() 103 | ); 104 | return firestoreService.snapshot_(proto, readTimeProto, 'json'); 105 | } 106 | 107 | /** Fetch an example document snapshot already populated with data. Can be passed into a wrapped 108 | * Firestore onCreate or onDelete function. 109 | */ 110 | export function exampleDocumentSnapshot(): firestore.DocumentSnapshot { 111 | return makeDocumentSnapshot( 112 | { 113 | aString: 'foo', 114 | anObject: { 115 | a: 'bar', 116 | b: 'faz', 117 | }, 118 | aNumber: 7, 119 | }, 120 | 'records/1234' 121 | ); 122 | } 123 | 124 | /** Fetch an example Change object of document snapshots already populated with data. 125 | * Can be passed into a wrapped Firestore onUpdate or onWrite function. 126 | */ 127 | export function exampleDocumentSnapshotChange(): Change< 128 | firestore.DocumentSnapshot 129 | > { 130 | return Change.fromObjects( 131 | makeDocumentSnapshot( 132 | { 133 | anObject: { 134 | a: 'bar', 135 | }, 136 | aNumber: 7, 137 | }, 138 | 'records/1234' 139 | ), 140 | makeDocumentSnapshot( 141 | { 142 | aString: 'foo', 143 | anObject: { 144 | a: 'qux', 145 | b: 'faz', 146 | }, 147 | aNumber: 7, 148 | }, 149 | 'records/1234' 150 | ) 151 | ); 152 | } 153 | 154 | /** @internal */ 155 | export function objectToValueProto(data: object) { 156 | const encodeHelper = (val) => { 157 | if (typeof val === 'string') { 158 | return { 159 | stringValue: val, 160 | }; 161 | } 162 | if (typeof val === 'boolean') { 163 | return { 164 | booleanValue: val, 165 | }; 166 | } 167 | if (typeof val === 'number') { 168 | if (val % 1 === 0) { 169 | return { 170 | integerValue: val, 171 | }; 172 | } 173 | return { 174 | doubleValue: val, 175 | }; 176 | } 177 | if (val instanceof Date) { 178 | return { 179 | timestampValue: val.toISOString(), 180 | }; 181 | } 182 | if (val instanceof Array) { 183 | let encodedElements = []; 184 | for (const elem of val) { 185 | const enc = encodeHelper(elem); 186 | if (enc) { 187 | encodedElements.push(enc); 188 | } 189 | } 190 | return { 191 | arrayValue: { 192 | values: encodedElements, 193 | }, 194 | }; 195 | } 196 | if (val === null) { 197 | // TODO: Look this up. This is a google.protobuf.NulLValue, 198 | // and everything in google.protobuf has a customized JSON encoder. 199 | // OTOH, Firestore's generated .d.ts files don't take this into 200 | // account and have the default proto layout. 201 | return { 202 | nullValue: 'NULL_VALUE', 203 | }; 204 | } 205 | if (val instanceof Buffer || val instanceof Uint8Array) { 206 | return { 207 | bytesValue: val, 208 | }; 209 | } 210 | if (val instanceof firestore.DocumentReference) { 211 | const projectId: string = get(val, '_referencePath.projectId'); 212 | const database: string = get(val, '_referencePath.databaseId'); 213 | const referenceValue: string = [ 214 | 'projects', 215 | projectId, 216 | 'databases', 217 | database, 218 | val.path, 219 | ].join('/'); 220 | return { referenceValue }; 221 | } 222 | if (val instanceof firestore.Timestamp) { 223 | return { 224 | timestampValue: val.toDate().toISOString(), 225 | }; 226 | } 227 | if (val instanceof firestore.GeoPoint) { 228 | return { 229 | geoPointValue: { 230 | latitude: val.latitude, 231 | longitude: val.longitude, 232 | }, 233 | }; 234 | } 235 | if (isPlainObject(val)) { 236 | return { 237 | mapValue: { 238 | fields: objectToValueProto(val), 239 | }, 240 | }; 241 | } 242 | throw new Error( 243 | 'Cannot encode ' + 244 | val + 245 | 'to a Firestore Value.' + 246 | ` Local testing does not yet support objects of type ${val?.constructor?.name}.` 247 | ); 248 | }; 249 | 250 | return mapValues(data, encodeHelper); 251 | } 252 | 253 | const FIRESTORE_ADDRESS_ENVS = [ 254 | 'FIRESTORE_EMULATOR_HOST', 255 | 'FIREBASE_FIRESTORE_EMULATOR_ADDRESS', 256 | ]; 257 | 258 | /** Clears all data in firestore. Works only in offline mode. 259 | */ 260 | export function clearFirestoreData(options: { projectId: string } | string) { 261 | const FIRESTORE_ADDRESS = FIRESTORE_ADDRESS_ENVS.reduce( 262 | (addr, name) => process.env[name] || addr, 263 | 'localhost:8080' 264 | ); 265 | 266 | return new Promise((resolve, reject) => { 267 | let projectId; 268 | 269 | if (typeof options === 'string') { 270 | projectId = options; 271 | } else if (typeof options === 'object' && has(options, 'projectId')) { 272 | projectId = options.projectId; 273 | } else { 274 | throw new Error('projectId not specified'); 275 | } 276 | 277 | const config = { 278 | method: 'DELETE', 279 | hostname: FIRESTORE_ADDRESS.split(':')[0], 280 | port: FIRESTORE_ADDRESS.split(':')[1], 281 | path: `/emulator/v1/projects/${projectId}/databases/(default)/documents`, 282 | }; 283 | 284 | const req = http.request(config, (res) => { 285 | if (res.statusCode !== 200) { 286 | reject(new Error(`statusCode: ${res.statusCode}`)); 287 | } 288 | res.on('data', () => {}); 289 | res.on('end', resolve); 290 | }); 291 | 292 | req.on('error', (error) => { 293 | reject(error); 294 | }); 295 | 296 | const postData = JSON.stringify({ 297 | database: `projects/${projectId}/databases/(default)`, 298 | }); 299 | 300 | req.setHeader('Content-Length', postData.length); 301 | 302 | req.write(postData); 303 | req.end(); 304 | }); 305 | } 306 | -------------------------------------------------------------------------------- /src/providers/pubsub.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { pubsub } from 'firebase-functions/v1'; 24 | 25 | /** Create a Message from a JSON object. */ 26 | export function makeMessage( 27 | /** Content of message. */ 28 | json: { [key: string]: any }, 29 | /** Optional Pubsub message attributes. */ 30 | attributes?: { [key: string]: string } 31 | ): pubsub.Message; 32 | 33 | /** Create a Message from a base-64 encoded string. */ 34 | export function makeMessage( 35 | /** Base-64 encoded message string. */ 36 | encodedString: string, 37 | /** Optional Pubsub message attributes. */ 38 | attributes?: { [key: string]: string } 39 | ): pubsub.Message; 40 | 41 | export function makeMessage( 42 | jsonOrEncodedString: { [key: string]: any } | string, 43 | attributes?: { [key: string]: string } 44 | ): pubsub.Message { 45 | let data = jsonOrEncodedString; 46 | if (typeof data !== 'string') { 47 | try { 48 | data = new Buffer(JSON.stringify(data)).toString('base64'); 49 | } catch (e) { 50 | throw new Error( 51 | 'Please provide either a JSON object or a base 64 encoded string.' 52 | ); 53 | } 54 | } 55 | return new pubsub.Message({ 56 | data, 57 | attributes: attributes || {}, 58 | }); 59 | } 60 | 61 | /** Fetch an example Message already populated with data. */ 62 | export function exampleMessage(): pubsub.Message { 63 | return makeMessage({ message: 'Hello World!' }); 64 | } 65 | -------------------------------------------------------------------------------- /src/providers/storage.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { storage } from 'firebase-functions/v1'; 24 | 25 | /** Create an ObjectMetadata */ 26 | export function makeObjectMetadata( 27 | /** Fields of ObjectMetadata that you'd like to specify. */ 28 | fields: { [key: string]: string } 29 | ): storage.ObjectMetadata { 30 | const configBucket = JSON.parse(process.env.FIREBASE_CONFIG || '{}') 31 | .storageBucket; 32 | const template = { 33 | kind: 'storage#object', 34 | id: '', 35 | bucket: configBucket || '', 36 | timeCreated: '', 37 | updated: '', 38 | storageClass: 'STANDARD', 39 | size: '', 40 | }; 41 | return Object.assign(template, fields); 42 | } 43 | 44 | /** Fetch an example ObjectMetadata already populated with data. */ 45 | export function exampleObjectMetadata(): storage.ObjectMetadata { 46 | return { 47 | bucket: 'bucket', 48 | contentDisposition: "inline; filename*=utf-8''my-file", 49 | contentType: 'application/octet-stream', 50 | crc32c: 'pbXl9g==', 51 | etag: 'CK/F2KHP79kCEAE=', 52 | generation: '1521161254347439', 53 | id: 'bucket/my-file/1521161254347439', 54 | kind: 'storage#object', 55 | md5Hash: 'nvpapVwyoKYUTPwuxMe3Sg==', 56 | mediaLink: 57 | 'https://www.googleapis.com/download/storage/v1/b/bucket/' + 58 | 'o/my-file?generation=1521161254347439&alt=media', 59 | metadata: { 60 | firebaseStorageDownloadTokens: 'fb577445-2f50-408b-80f2-c2f9991505b8', 61 | }, 62 | metageneration: '1', 63 | name: 'my-file', 64 | selfLink: 'https://www.googleapis.com/storage/v1/b/bucket/o/my-file', 65 | size: '102101', 66 | storageClass: 'STANDARD', 67 | timeCreated: '2018-03-16T00:47:34.340Z', 68 | timeStorageClassUpdated: '2018-03-16T00:47:34.340Z', 69 | updated: '2018-03-16T00:47:34.340Z', 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/secretManager.ts: -------------------------------------------------------------------------------- 1 | /** Mock values returned by `functions.config()`. */ 2 | export function mockSecretManager(conf: Record) { 3 | for (const [key, value] of Object.entries(conf)) { 4 | process.env[key] = value; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/v1.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { has, merge, random, get } from 'lodash'; 24 | 25 | import { 26 | CloudFunction, 27 | EventContext, 28 | Change, 29 | https, 30 | config, 31 | database, 32 | firestore, 33 | HttpsFunction, 34 | Runnable, 35 | // @ts-ignore 36 | resetCache, 37 | } from 'firebase-functions/v1'; 38 | import { Expression } from 'firebase-functions/params'; 39 | import { 40 | getEventFilters, 41 | getEventType, 42 | resolveStringExpression, 43 | } from './cloudevent/mocks/helpers'; 44 | 45 | /** Fields of the event context that can be overridden/customized. */ 46 | export type EventContextOptions = { 47 | /** ID of the event. If omitted, a random ID will be generated. */ 48 | eventId?: string; 49 | /** ISO time string of when the event occurred. If omitted, the current time is used. */ 50 | timestamp?: string; 51 | /** The values for the wildcards in the reference path that a database or Firestore function is listening to. 52 | * If omitted, random values will be generated. 53 | */ 54 | params?: { [option: string]: any }; 55 | /** (Only for database functions and https.onCall.) Firebase auth variable representing the user that triggered 56 | * the function. Defaults to null. 57 | */ 58 | auth?: any; 59 | /** (Only for database and https.onCall functions.) The authentication state of the user that triggered the function. 60 | * Default is 'UNAUTHENTICATED'. 61 | */ 62 | authType?: 'ADMIN' | 'USER' | 'UNAUTHENTICATED'; 63 | 64 | /** Resource is a standard format for defining a resource (google.rpc.context.AttributeContext.Resource). 65 | * In Cloud Functions, it is the resource that triggered the function - such as a storage bucket. 66 | */ 67 | resource?: { 68 | service: string; 69 | name: string; 70 | type?: string; 71 | labels?: { 72 | [tag: string]: string; 73 | }; 74 | }; 75 | }; 76 | 77 | /** Fields of the callable context that can be overridden/customized. */ 78 | export type CallableContextOptions = { 79 | /** 80 | * The result of decoding and verifying a Firebase AppCheck token. 81 | */ 82 | app?: any; 83 | 84 | /** 85 | * The result of decoding and verifying a Firebase Auth ID token. 86 | */ 87 | auth?: any; 88 | 89 | /** 90 | * An unverified token for a Firebase Instance ID. 91 | */ 92 | instanceIdToken?: string; 93 | 94 | /** 95 | * The raw HTTP request object. 96 | */ 97 | rawRequest?: https.Request; 98 | }; 99 | 100 | /* Fields for both Event and Callable contexts, checked at runtime */ 101 | export type ContextOptions = T extends HttpsFunction & Runnable 102 | ? CallableContextOptions 103 | : EventContextOptions; 104 | 105 | /** A function that can be called with test data and optional override values for the event context. 106 | * It will subsequently invoke the cloud function it wraps with the provided test data and a generated event context. 107 | */ 108 | export type WrappedFunction = ( 109 | data: T, 110 | options?: ContextOptions 111 | ) => any | Promise; 112 | 113 | /** A scheduled function that can be called with optional override values for the event context. 114 | * It will subsequently invoke the cloud function it wraps with a generated event context. 115 | */ 116 | export type WrappedScheduledFunction = ( 117 | options?: ContextOptions 118 | ) => any | Promise; 119 | 120 | /** Takes a cloud function to be tested, and returns a WrappedFunction which can be called in test code. */ 121 | export function wrapV1( 122 | cloudFunction: HttpsFunction & Runnable 123 | ): WrappedFunction>; 124 | export function wrapV1( 125 | cloudFunction: CloudFunction 126 | ): WrappedScheduledFunction | WrappedFunction> { 127 | if (!has(cloudFunction, '__endpoint')) { 128 | throw new Error( 129 | 'Wrap can only be called on functions written with the firebase-functions SDK.' 130 | ); 131 | } 132 | 133 | if (has(cloudFunction, '__endpoint.scheduleTrigger')) { 134 | const scheduledWrapped: WrappedScheduledFunction = ( 135 | options: ContextOptions 136 | ) => { 137 | // Although in Typescript we require `options` some of our JS samples do not pass it. 138 | options = options || {}; 139 | 140 | _checkOptionValidity(['eventId', 'timestamp'], options); 141 | const defaultContext = _makeDefaultContext(cloudFunction, options); 142 | const context = merge({}, defaultContext, options); 143 | 144 | // @ts-ignore 145 | return cloudFunction.run(context); 146 | }; 147 | return scheduledWrapped; 148 | } 149 | 150 | if (has(cloudFunction, '__endpoint.httpsTrigger')) { 151 | throw new Error( 152 | 'Wrap function is only available for `onCall` HTTP functions, not `onRequest`.' 153 | ); 154 | } 155 | 156 | if (!has(cloudFunction, 'run')) { 157 | throw new Error( 158 | 'This library can only be used with functions written with firebase-functions v1.0.0 and above' 159 | ); 160 | } 161 | 162 | const isCallableFunction = has(cloudFunction, '__endpoint.callableTrigger'); 163 | 164 | let wrapped: WrappedFunction = (data, options) => { 165 | // Although in Typescript we require `options` some of our JS samples do not pass it. 166 | const _options = { ...options }; 167 | let context; 168 | 169 | if (isCallableFunction) { 170 | _checkOptionValidity( 171 | ['app', 'auth', 'instanceIdToken', 'rawRequest'], 172 | _options 173 | ); 174 | let callableContextOptions = _options as CallableContextOptions; 175 | context = { 176 | ...callableContextOptions, 177 | }; 178 | } else { 179 | _checkOptionValidity( 180 | ['eventId', 'timestamp', 'params', 'auth', 'authType', 'resource'], 181 | _options 182 | ); 183 | const defaultContext = _makeDefaultContext(cloudFunction, _options, data); 184 | 185 | if ( 186 | has(defaultContext, 'eventType') && 187 | defaultContext.eventType !== undefined && 188 | defaultContext.eventType.match(/firebase.database/) 189 | ) { 190 | defaultContext.authType = 'UNAUTHENTICATED'; 191 | defaultContext.auth = null; 192 | } 193 | context = merge({}, defaultContext, _options); 194 | } 195 | 196 | return cloudFunction.run(data, context); 197 | }; 198 | 199 | return wrapped; 200 | } 201 | 202 | /** @internal */ 203 | export function _makeResourceName( 204 | triggerResource: string | Expression, 205 | params = {} 206 | ): string { 207 | const resource = resolveStringExpression(triggerResource); 208 | const wildcardRegex = new RegExp('{[^/{}]*}', 'g'); 209 | let resourceName = resource.replace(wildcardRegex, (wildcard) => { 210 | let wildcardNoBraces = wildcard.slice(1, -1); // .slice removes '{' and '}' from wildcard 211 | let sub = get(params, wildcardNoBraces); 212 | return sub || wildcardNoBraces + random(1, 9); 213 | }); 214 | return resourceName; 215 | } 216 | 217 | function _makeEventId(): string { 218 | return ( 219 | Math.random() 220 | .toString(36) 221 | .substring(2, 15) + 222 | Math.random() 223 | .toString(36) 224 | .substring(2, 15) 225 | ); 226 | } 227 | 228 | function _checkOptionValidity( 229 | validFields: string[], 230 | options: { [s: string]: any } 231 | ) { 232 | Object.keys(options).forEach((key) => { 233 | if (validFields.indexOf(key) === -1) { 234 | throw new Error( 235 | `Options object ${JSON.stringify(options)} has invalid key "${key}"` 236 | ); 237 | } 238 | }); 239 | } 240 | 241 | function _makeDefaultContext( 242 | cloudFunction: HttpsFunction & Runnable, 243 | options: CallableContextOptions, 244 | triggerData?: T 245 | ); 246 | function _makeDefaultContext( 247 | cloudFunction: CloudFunction, 248 | options: EventContextOptions, 249 | triggerData?: T 250 | ): EventContext { 251 | let eventContextOptions = options as EventContextOptions; 252 | const eventType = getEventType(cloudFunction); 253 | const eventResource = getEventFilters(cloudFunction).resource; 254 | 255 | const optionsParams = eventContextOptions.params ?? {}; 256 | let triggerParams = {}; 257 | if (eventResource && eventType && triggerData) { 258 | if (eventType.startsWith('google.firebase.database.ref.')) { 259 | let data: database.DataSnapshot; 260 | if (eventType.endsWith('.write')) { 261 | // Triggered with change 262 | if (!(triggerData instanceof Change)) { 263 | throw new Error('Must be triggered by database change'); 264 | } 265 | data = triggerData.before; 266 | } else { 267 | data = triggerData as any; 268 | } 269 | triggerParams = _extractDatabaseParams(eventResource, data); 270 | } else if (eventType.startsWith('google.firestore.document.')) { 271 | let data: firestore.DocumentSnapshot; 272 | if (eventType.endsWith('.write')) { 273 | // Triggered with change 274 | if (!(triggerData instanceof Change)) { 275 | throw new Error('Must be triggered by firestore document change'); 276 | } 277 | data = triggerData.before; 278 | } else { 279 | data = triggerData as any; 280 | } 281 | triggerParams = _extractFirestoreDocumentParams(eventResource, data); 282 | } 283 | } 284 | const params = { ...triggerParams, ...optionsParams }; 285 | 286 | const defaultContext: EventContext = { 287 | eventId: _makeEventId(), 288 | resource: eventResource && { 289 | service: serviceFromEventType(eventType), 290 | name: _makeResourceName(eventResource, params), 291 | }, 292 | eventType, 293 | timestamp: new Date().toISOString(), 294 | params, 295 | }; 296 | return defaultContext; 297 | } 298 | 299 | function _extractDatabaseParams( 300 | triggerResource: string | Expression, 301 | data: database.DataSnapshot 302 | ): EventContext['params'] { 303 | const resource = resolveStringExpression(triggerResource); 304 | const path = data.ref.toString().replace(data.ref.root.toString(), ''); 305 | return _extractParams(resource, path); 306 | } 307 | 308 | function _extractFirestoreDocumentParams( 309 | triggerResource: string | Expression, 310 | data: firestore.DocumentSnapshot 311 | ): EventContext['params'] { 312 | const resource = resolveStringExpression(triggerResource); 313 | // Resource format: databases/(default)/documents/ 314 | return _extractParams( 315 | resource.replace(/^databases\/[^\/]+\/documents\//, ''), 316 | data.ref.path 317 | ); 318 | } 319 | 320 | /** 321 | * Extracts the `{wildcard}` values from `dataPath`. 322 | * E.g. A wildcard path of `users/{userId}` with `users/FOO` would result in `{ userId: 'FOO' }`. 323 | * @internal 324 | */ 325 | export function _extractParams( 326 | wildcardTriggerPath: string, 327 | dataPath: string 328 | ): EventContext['params'] { 329 | // Trim start and end / and split into path components 330 | const wildcardPaths = wildcardTriggerPath 331 | .replace(/^\/?(.*?)\/?$/, '$1') 332 | .split('/'); 333 | const dataPaths = dataPath.replace(/^\/?(.*?)\/?$/, '$1').split('/'); 334 | const params = {}; 335 | if (wildcardPaths.length === dataPaths.length) { 336 | for (let idx = 0; idx < wildcardPaths.length; idx++) { 337 | const wildcardPath = wildcardPaths[idx]; 338 | const name = wildcardPath.replace(/^{([^/{}]*)}$/, '$1'); 339 | if (name !== wildcardPath) { 340 | // Wildcard parameter 341 | params[name] = dataPaths[idx]; 342 | } 343 | } 344 | } 345 | return params; 346 | } 347 | 348 | function serviceFromEventType(eventType?: string): string { 349 | if (eventType) { 350 | const providerToService: Array<[string, string]> = [ 351 | ['google.analytics', 'app-measurement.com'], 352 | ['google.firebase.auth', 'firebaseauth.googleapis.com'], 353 | ['google.firebase.database', 'firebaseio.com'], 354 | ['google.firestore', 'firestore.googleapis.com'], 355 | ['google.pubsub', 'pubsub.googleapis.com'], 356 | ['google.firebase.remoteconfig', 'firebaseremoteconfig.googleapis.com'], 357 | ['google.storage', 'storage.googleapis.com'], 358 | ['google.testing', 'testing.googleapis.com'], 359 | ]; 360 | 361 | const match = providerToService.find(([provider]) => { 362 | eventType.includes(provider); 363 | }); 364 | if (match) { 365 | return match[1]; 366 | } 367 | } 368 | return 'unknown-service.googleapis.com'; 369 | } 370 | 371 | /** Make a Change object to be used as test data for Firestore and real time database onWrite and onUpdate functions. */ 372 | export function makeChange(before: T, after: T): Change { 373 | return Change.fromObjects(before, after); 374 | } 375 | 376 | /** Mock values returned by `functions.config()`. */ 377 | export function mockConfig(conf: { [key: string]: { [key: string]: any } }) { 378 | if (resetCache) { 379 | resetCache(); 380 | } 381 | process.env.CLOUD_RUNTIME_CONFIG = JSON.stringify(conf); 382 | } 383 | -------------------------------------------------------------------------------- /src/v2.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2018 Firebase 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. 22 | 23 | import { CloudFunction, CloudEvent } from 'firebase-functions/v2'; 24 | import { CallableFunction, CallableRequest } from 'firebase-functions/v2/https'; 25 | 26 | import { generateCombinedCloudEvent } from './cloudevent/generate'; 27 | import { DeepPartial } from './cloudevent/types'; 28 | import * as express from 'express'; 29 | 30 | /** A function that can be called with test data and optional override values for {@link CloudEvent} 31 | * It will subsequently invoke the cloud function it wraps with the provided {@link CloudEvent} 32 | */ 33 | export type WrappedV2Function> = ( 34 | cloudEventPartial?: DeepPartial 35 | ) => any | Promise; 36 | 37 | export type WrappedV2CallableFunction = ( 38 | data: CallableRequest 39 | ) => T | Promise; 40 | 41 | function isCallableV2Function>( 42 | cf: CloudFunction | CallableFunction 43 | ): cf is CallableFunction { 44 | return !!cf?.__endpoint?.callableTrigger; 45 | } 46 | 47 | function assertIsCloudFunction>( 48 | cf: CloudFunction | CallableFunction 49 | ): asserts cf is CloudFunction { 50 | if (!('run' in cf) || !cf.run) { 51 | throw new Error( 52 | 'This library can only be used with functions written with firebase-functions v3.20.0 and above' 53 | ); 54 | } 55 | } 56 | 57 | /** 58 | * Takes a v2 cloud function to be tested, and returns a {@link WrappedV2Function} 59 | * which can be called in test code. 60 | */ 61 | export function wrapV2>( 62 | cloudFunction: CloudFunction 63 | ): WrappedV2Function; 64 | 65 | /** 66 | * Takes a v2 HTTP function to be tested, and returns a {@link WrappedV2HttpsFunction} 67 | * which can be called in test code. 68 | */ 69 | export function wrapV2( 70 | cloudFunction: CallableFunction 71 | ): WrappedV2CallableFunction; 72 | 73 | export function wrapV2>( 74 | cloudFunction: CloudFunction | CallableFunction 75 | ): WrappedV2Function | WrappedV2CallableFunction { 76 | if (isCallableV2Function(cloudFunction)) { 77 | return (req: CallableRequest) => { 78 | return cloudFunction.run(req); 79 | }; 80 | } 81 | 82 | assertIsCloudFunction(cloudFunction); 83 | 84 | if (cloudFunction?.__endpoint?.platform !== 'gcfv2') { 85 | throw new Error('This function can only wrap V2 CloudFunctions.'); 86 | } 87 | 88 | return (cloudEventPartial?: DeepPartial) => { 89 | const cloudEvent = generateCombinedCloudEvent( 90 | cloudFunction, 91 | cloudEventPartial 92 | ); 93 | return cloudFunction.run(cloudEvent); 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es6"], 4 | "module": "commonjs", 5 | "noImplicitAny": false, 6 | "outDir": ".tmp", 7 | "sourceMap": true, 8 | "target": "es6", 9 | "typeRoots": ["node_modules/@types"] 10 | }, 11 | "include": ["src/**/*.ts", "spec/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "lib": ["es6"], 5 | "module": "commonjs", 6 | "noImplicitAny": false, 7 | "outDir": "lib", 8 | "stripInternal": true, 9 | "target": "es6", 10 | "typeRoots": ["node_modules/@types"] 11 | }, 12 | "files": ["src/index.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "quotemark": [true, "single", "avoid-escape"], 5 | "interface-name": [false], 6 | "interface-over-type-literal": false, 7 | "variable-name": [true, "check-format", "allow-leading-underscore"], 8 | "object-literal-sort-keys": false, 9 | "ordered-imports": false, 10 | "prefer-const": false, 11 | "trailing-comma": [ 12 | true, 13 | { 14 | "functions": "never" 15 | } 16 | ], 17 | "whitespace": [true], 18 | "member-access": [false], 19 | "no-console": [false], 20 | "no-namespace": [false], 21 | "no-unused-expression": false, 22 | "no-empty": false, 23 | "unified-signatures": false 24 | } 25 | } 26 | --------------------------------------------------------------------------------