prescottprue/fireadmin

View on GitHub
functions/src/actionRunner/actionRunner.spec.ts

Summary

Maintainability
F
2 wks
Test Coverage
import 'mocha'
import * as admin from 'firebase-admin'
import fs from 'fs'
import { expect } from 'chai'
import * as sinon from 'sinon'
import 'sinon-chai'
import { to } from '../utils/async'
import { encrypt } from '../utils/encryption'
import functionsTestLib from 'firebase-functions-test'

const functionsTest = functionsTestLib()

const responsePath = 'responses/actionRunner/1'
const createdAt = 'timestamp'
const existingProjectId = 'existing'

describe('actionRunner RTDB Cloud Function (RTDB:onCreate)', function () {
  (this as any).timeout(20000)
  let actionRunner
  let adminInitStub
  let databaseStub
  let setStub
  let refStub
  let docStub
  let collectionStub
  let parentStorageStub
  let parentFirestoreStub

  before(() => {
    parentStorageStub = sinon.stub().returns({
      bucket: sinon.stub().returns({
        file: sinon.stub().returns({
          delete: sinon.stub().returns(Promise.resolve({})),
          // Mock download method with invalid JSON file data
          download: sinon.spy(({ destination }) => {
            fs.writeFileSync(
              destination,
              JSON.stringify({ asdf: 'asdf' }, null, 2)
            )
            return Promise.resolve({ asdf: 'asdf' })
          })
        }),
        upload: sinon.stub().returns(Promise.resolve({}))
      })
    })
    // Stub Firebase's admin.initializeApp()
    parentFirestoreStub = sinon.stub().returns({
      collection: sinon.stub().returns({
        doc: sinon.stub().returns({
          get: sinon.stub().returns(Promise.resolve({ data: () => ({}) })),
          set: sinon.stub().returns(Promise.resolve(null)),
          path: 'projects/my-project'
        }),
        path: 'projects',
        firestore: {
          batch: sinon.stub().returns({
            commit: sinon.stub().returns(Promise.resolve()),
            set: sinon.stub().returns({})
          }),
          doc: sinon.stub().returns({
            get: sinon.stub().returns(
              Promise.resolve({
                data: () => ({ some: 'value' }),
                exists: true
              })
            )
          })
        },
        get: sinon.stub().returns(
          Promise.resolve({
            data: () => ({ some: 'value' }),
            exists: true
          })
        )
      }),
      doc: sinon.stub().returns({
        firestore: {
          batch: sinon.stub().returns({
            commit: sinon.stub().returns(Promise.resolve()),
            set: sinon.stub().returns({})
          }),
          doc: sinon.stub().returns({
            get: sinon.stub().returns(
              Promise.resolve({
                data: () => ({ some: 'value' }),
                exists: true
              })
            )
          })
        },
        update: sinon.stub().returns(Promise.resolve()),
        get: sinon
        .stub()
        .returns(
          Promise.resolve({ data: () => ({ some: 'value' }), exists: true, get: () => 'token' })
        ),
      })
    })

    parentFirestoreStub.batch = sinon.stub().returns({
      set: sinon.stub().returns(Promise.resolve()),
      commit: sinon.stub().returns(Promise.resolve())
    })
    adminInitStub = sinon.stub(admin, 'initializeApp').returns(({
      firestore: parentFirestoreStub,
      database: sinon.stub().returns({
        ref: sinon.stub().returns({
          once: sinon.stub().returns(Promise.resolve({ val: () => ({}) })),
          update: sinon.stub().returns(Promise.resolve())
        })
      }),
      storage: parentStorageStub
    } as any))
    sinon.stub(admin.credential, 'cert')
  })

  after(() => {
    // Restore firebase-admin stub to the original
    adminInitStub.restore()
  })

  beforeEach(() => {
    // Stub Firebase's functions.config() (default in test/setup)
    functionsTest.mockConfig({
      firebase: {
        databaseURL: 'https://some-project.firebaseio.com'
      },
      encryption: {
        password: 'asdf'
      },
      algolia: {
        app_id: 'asdf',
        api_key: 'asdf'
      },
    })

    // Stubs for Firestore methods
    docStub = sinon.stub().returns({
      set: sinon.stub().returns(Promise.resolve({})),
      get: sinon.stub().returns(Promise.resolve({ get: () => {} })),
      collection: sinon.stub().returns({
        add: sinon.stub().returns(Promise.resolve({})),
        doc: docStub
      })
    })
    docStub
      .withArgs(`projects/${existingProjectId}/environments/asdf`)
      .returns({
        get: sinon.stub().returns(
          Promise.resolve({
            data: () => ({ serviceAccount: { credential: 'asdf' } }),
            exists: true
          })
        )
      })
    collectionStub = sinon.stub().returns({
      add: sinon.stub().returns(Promise.resolve({})),
      doc: docStub
    })

    // Create Firestore stub out of stubbed methods
    const firestoreStub = sinon
      .stub()
      .returns({ doc: docStub, collection: collectionStub })

    // Apply stubs as admin.firestore()
    sinon.stub(admin, 'firestore').get(() => firestoreStub)
    admin.firestore.FieldValue = ({ serverTimestamp: sinon.stub(() => createdAt) } as any)

    // Stubs for RTDB methods
    setStub = sinon.stub().returns(Promise.resolve({ ref: 'new_ref' }))
    refStub = sinon.stub().returns({
      set: setStub,
      update: setStub,
      push: sinon.stub().returns(Promise.resolve({}))
    })
    databaseStub = sinon.stub().returns({ ref: refStub })
    databaseStub.ServerValue = { TIMESTAMP: 'test' }

    // Apply stubs as admin.database()
    sinon.stub(admin, 'database').get(() => databaseStub)

    // Load wrapped version of Cloud Function
    actionRunner = functionsTest.wrap(
      require(`${__dirname}/../../src/actionRunner`).default
    )
    /* eslint-enable global-require */
  })

  afterEach(() => {
    // Restoring our test-level stubs to the original methods
    functionsTest.cleanup()
  })

  describe('Invalid Action Template', () => {
    it('Throws and updates error if projectId is undefined', async () => {
      const snap = {
        val: () => ({})
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Confir error thrown with correct message
      expect(err).to.have.property('message', 'projectId parameter is required')
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledOnce
    })

    it('Throws if action template is not included', async () => {
      const snap = {
        val: () => ({ projectId: 'test' }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        'Valid Action Template is required to run steps'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error: 'Valid Action Template is required to run steps',
        status: 'error'
      })
    })

    it('Throws if action template is not an object', async () => {
      const snap = {
        val: () => ({ projectId: 'test', template: 'asdf' }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        'Valid Action Template is required to run steps'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error: 'Valid Action Template is required to run steps',
        status: 'error'
      })
    })

    it('Throws if action template does not contain steps', async () => {
      const snap = {
        val: () => ({ projectId: 'test', template: { asdf: 'asdf' } }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        'Valid Action Template is required to run steps'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error: 'Valid Action Template is required to run steps',
        status: 'error'
      })
    })

    it('Throws if action template does not contain inputs', async () => {
      const snap = {
        val: () => ({ projectId: 'test', template: { steps: [] } }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        'Inputs array was not provided to action request'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error: 'Inputs array was not provided to action request',
        status: 'error'
      })
    })

    it('Throws if action template does not contain inputValues', async () => {
      const snap = {
        val: () => ({ projectId: 'test', template: { steps: [], inputs: [] } }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        'Input values array was not provided to action request'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error: 'Input values array was not provided to action request',
        status: 'error'
      })
    })

    it('Throws if inputValues are not passed', async () => {
      const snap = {
        val: () => ({
          projectId: 'test',
          template: { steps: [], inputs: [] }
        }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        'Input values array was not provided to action request'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error: 'Input values array was not provided to action request',
        status: 'error'
      })
    })

    it('Throws if environment does not have databaseURL', async () => {
      const snap = {
        val: () => ({
          projectId: 'test',
          inputValues: [],
          environments: [{ type: 'test' }],
          template: { steps: [], inputs: [] }
        }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        'databaseURL is required for action to authenticate through serviceAccount'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error:
          'databaseURL is required for action to authenticate through serviceAccount',
        status: 'error'
      })
    })

    it('Throws if environment does not an environment key or id', async () => {
      const snap = {
        val: () => ({
          projectId: 'test',
          inputValues: [],
          environments: [
            { databaseURL: 'https://some-project.firebaseio.com' }
          ],
          template: { steps: [], inputs: [] }
        }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        'environmentKey or id is required for action to authenticate through serviceAccount'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error:
          'environmentKey or id is required for action to authenticate through serviceAccount',
        status: 'error'
      })
    })

    it('Throws if project does not contain serviceAccount', async () => {
      const id = 'asdf'
      const projectId = 'another'
      const snap = {
        val: () => ({
          projectId,
          inputValues: [],
          environments: [
            { databaseURL: 'https://some-project.firebaseio.com', id }
          ],
          template: { steps: [], inputs: [] }
        }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        `Project containing service account not at path: projects/${projectId}/environments/${id}`
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error: `Project containing service account not at path: projects/${projectId}/environments/${id}`,
        status: 'error'
      })
    })

    it('Throws if provided an invalid service account object (i.e. a string that is not an encrypted object)', async () => {
      const snap = {
        val: () => ({
          projectId: existingProjectId,
          inputValues: [],
          environments: [
            { databaseURL: 'https://some-project.firebaseio.com', id: 'asdf' }
          ],
          template: { steps: [], inputs: [] }
        }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confir error thrown with correct message
      expect(err).to.have.property(
        'message',
        'Service account not a valid object'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Error object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error: 'Service account not a valid object',
        status: 'error'
      })
    })

    it('Throws if template contains invalid steps', async () => {
      const validProjectId = 'aosidjfoaisjdfoi'
      docStub.withArgs(`projects/${validProjectId}/environments/asdf`).returns({
        get: sinon.stub().returns(
          Promise.resolve({
            data: () => ({
              serviceAccount: {
                credential: encrypt(({
                  type: 'service_account',
                  project_id: 'asdf',
                  private_key_id: 'asdf',
                  private_key: 'asdf',
                  client_email: 'asdf',
                  client_id: 'sadf',
                  auth_uri: 'https://accounts.google.com/o/oauth2/auth',
                  token_uri: 'https://accounts.google.com/o/oauth2/token',
                  auth_provider_x509_cert_url:
                    'https://www.googleapis.com/oauth2/v1/certs',
                  client_x509_cert_url: 'asdf'
                } as any))
              }
            }),
            exists: true
          })
        )
      })
      const snap = {
        val: () => ({
          projectId: validProjectId,
          inputValues: ['projects'],
          environments: [
            {
              databaseURL: 'https://some-project.firebaseio.com',
              id: 'asdf'
            }
          ],
          template: {
            steps: [
              {
                type: 'copy'
              }
            ],
            inputs: []
          }
        }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const [err] = await to(actionRunner(snap, fakeContext))
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confirm res
      expect(err).to.have.property(
        'message',
        'Error running step: 0 : src, dest and src.resource are required to run step'
      )
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Success object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        error:
          'Error running step: 0 : src, dest and src.resource are required to run step',
        status: 'error'
      })
    })
  })

  describe('Action template with type "copy"', () => {
    /**
     * @param opts
     */
    function createValidActionRunnerStubs(opts?: any) {
      const {
        projectId = 'asdfasdf1',
        srcResource = 'rtdb',
        destResource = 'rtdb',
        inputValues = ['projects']
      } = opts || {}
      // Environment Doc Stub (subcollection of project document)
      const environmentDocStub = docStub
        .withArgs(`projects/${projectId}/environments/asdf`)
        .returns({
          get: sinon.stub().returns(
            Promise.resolve({
              data: () => ({
                serviceAccount: {
                  credential: encrypt(({
                    type: 'service_account',
                    project_id: 'asdf',
                    private_key_id: 'asdf',
                    private_key: 'asdf',
                    client_email: 'asdf',
                    client_id: 'sadf',
                    auth_uri: 'https://accounts.google.com/o/oauth2/auth',
                    token_uri: 'https://accounts.google.com/o/oauth2/token',
                    auth_provider_x509_cert_url:
                      'https://www.googleapis.com/oauth2/v1/certs',
                    client_x509_cert_url: 'asdf'
                  } as any))
                }
              }),
              exists: true
            })
          )
        })
      // Event DataSnapshot stub
      const snapStub = {
        val: () => ({
          projectId,
          inputValues,
          environments: [
            {
              databaseURL: 'https://some-project.firebaseio.com',
              id: 'asdf'
            },
            {
              databaseURL: 'https://some-project.firebaseio.com',
              id: 'asdf'
            }
          ],
          template: {
            steps: [
              {
                type: 'copy',
                dest: { pathType: 'input', path: 0, resource: destResource },
                src: { pathType: 'input', path: 0, resource: srcResource }
              }
            ],
            inputs: [{ type: 'userInput' }]
          }
        }),
        ref: refStub()
      }
      return {
        snapStub,
        environmentDocStub
      }
    }

    describe('with src: "firestore" and dest: "firestore"', () => {
      it('successfully copies a single document between Firestore instances', async () => {
        const { snapStub } = createValidActionRunnerStubs({
          srcResource: 'firestore',
          destResource: 'firestore',
          inputValues: ['projects/my-project']
        })
        const fakeContext = {
          params: { pushId: 1 }
        }
        // Invoke with fake event object
        const res = await actionRunner(snapStub, fakeContext)
        // Response marked as started
        expect(setStub).to.have.been.calledWith({
          startedAt: 'test',
          status: 'started'
        })
        // Confirm res
        expect(res).to.be.null
        // Ref for response is correct path
        expect(refStub).to.have.been.calledWith(responsePath)
        // Success object written to response
        expect(setStub).to.have.been.calledWith({
          completed: true,
          completedAt: 'test',
          status: 'success'
        })
      })

      it('successfully copies multiple documents between Firestore instances', async function () {
        // this.retries(3) // retry to avoid file already exists error for serviceAccount
        const { snapStub } = createValidActionRunnerStubs({
          srcResource: 'firestore',
          destResource: 'firestore'
        })
        const fakeContext = {
          params: { pushId: 1 }
        }
        // Invoke with fake event object
        const res = await actionRunner(snapStub, fakeContext)
        // Response marked as started
        expect(setStub).to.have.been.calledWith({
          startedAt: 'test',
          status: 'started'
        })
        // Confirm res
        expect(res).to.be.null
        // Ref for response is correct path
        expect(refStub).to.have.been.calledWith(responsePath)
        // Success object written to response
        expect(setStub).to.have.been.calledWith({
          completed: true,
          completedAt: 'test',
          status: 'success'
        })
      })
    })

    describe('with src: "firestore" and dest: "rtdb"', () => {
      it('successfully copies from Firestore to Real Time Database', async () => {
        const { snapStub } = createValidActionRunnerStubs({
          srcResource: 'firestore',
          destResource: 'rtdb'
        })
        const fakeContext = {
          params: { pushId: 1 }
        }
        // Invoke with fake event object
        const res = await actionRunner(snapStub, fakeContext)
        // Response marked as started
        expect(setStub).to.have.been.calledWith({
          startedAt: 'test',
          status: 'started'
        })
        // Confirm res
        expect(res).to.be.null
        // Ref for response is correct path
        expect(refStub).to.have.been.calledWith(responsePath)
        // Success object written to response
        expect(setStub).to.have.been.calledWith({
          completed: true,
          completedAt: 'test',
          status: 'success'
        })
      })
    })

    describe('with src "rtdb" and dest: "rtdb"', () => {
      it('successfully copies between RTDB instances', async () => {
        const { snapStub } = createValidActionRunnerStubs()
        const fakeContext = {
          params: { pushId: 1 }
        }
        // Invoke with fake event object
        const res = await actionRunner(snapStub, fakeContext)
        // Response marked as started
        expect(setStub).to.have.been.calledWith({
          startedAt: 'test',
          status: 'started'
        })
        // Confirm res
        expect(res).to.be.null
        // Ref for response is correct path
        expect(refStub).to.have.been.calledWith(responsePath)
        // Success object written to response
        expect(setStub).to.have.been.calledWith({
          completed: true,
          completedAt: 'test',
          status: 'success'
        })
      })
    })

    describe('with src: "rtdb" and dest: "firestore"', () => {
      it('successfully copies from RTDB to Firestore', async () => {
        const { snapStub } = createValidActionRunnerStubs({
          srcResource: 'rtdb',
          destResource: 'firestore'
        })
        const fakeContext = {
          params: { pushId: 1 }
        }
        // Invoke with fake event object
        const res = await actionRunner(snapStub, fakeContext)
        // Response marked as started
        expect(setStub).to.have.been.calledWith({
          startedAt: 'test',
          status: 'started'
        })
        // Confirm res
        expect(res).to.be.null
        // Ref for response is correct path
        expect(refStub).to.have.been.calledWith(responsePath)
        // Success object written to response
        expect(setStub).to.have.been.calledWith({
          completed: true,
          completedAt: 'test',
          status: 'success'
        })
      })
    })

    describe('with src: "rtdb" and dest: "storage"', () => {
      // Skipped due to "Error running step: 0 : ENOENT: no such file or directory, open"
      it.skip('successfully copies from RTDB to Cloud Storage', async () => {
        const { snapStub } = createValidActionRunnerStubs({
          srcResource: 'rtdb',
          destResource: 'storage'
        })
        const fakeContext = {
          params: { pushId: 1 }
        }
        // Invoke with fake event object
        const res = await actionRunner(snapStub, fakeContext)
        // Response marked as started
        expect(setStub).to.have.been.calledWith({
          startedAt: 'test',
          status: 'started'
        })
        // Confirm res
        expect(res).to.be.null
        // Ref for response is correct path
        expect(refStub).to.have.been.calledWith(responsePath)
        // Success object written to response
        expect(setStub).to.have.been.calledWith({
          completed: true,
          completedAt: 'test',
          status: 'success'
        })
      })
    })

    describe('with src: "storage" and dest: "rtdb"', () => {
      it('successfully copies from Cloud Storage to RTDB', async () => {
        const { snapStub } = createValidActionRunnerStubs({
          srcResource: 'storage',
          destResource: 'rtdb'
        })
        const fakeContext = {
          params: { pushId: 1 }
        }
        // Invoke with fake event object
        const res = await actionRunner(snapStub, fakeContext)
        // Response marked as started
        expect(setStub).to.have.been.calledWith({
          startedAt: 'test',
          status: 'started'
        })
        // Confirm res
        expect(res).to.be.null
        // Ref for response is correct path
        expect(refStub).to.have.been.calledWith(responsePath)
        // Success object written to response
        expect(setStub).to.have.been.calledWith({
          completed: true,
          completedAt: 'test',
          status: 'success'
        })
      })
    })
  })

  describe('Action template with backups', () => {
    it('Calls backups before running steps', async () => {
      const snap = {
        val: () => ({
          projectId: 'test',
          inputValues: [],
          template: { steps: [], inputs: [], backups: [] }
        }),
        ref: refStub()
      }
      const fakeContext = {
        params: { pushId: 1 }
      }
      // Invoke with fake event object
      const res = await actionRunner(snap, fakeContext)
      // Response marked as started
      expect(setStub).to.have.been.calledWith({
        startedAt: 'test',
        status: 'started'
      })
      // Confirm res
      expect(res).to.be.null
      // Ref for response is correct path
      expect(refStub).to.have.been.calledWith(responsePath)
      // Success object written to response
      expect(setStub).to.have.been.calledWith({
        completed: true,
        completedAt: 'test',
        status: 'success'
      })
    })
  })
})