Source: client.js

import _ from 'lodash'
import axios from 'axios'
import DeviceDTO from './device-dto.js'
import JobDTO from './job-dto.js'
import TestResultDTO from './test-result-dto.js'
import TestRunDTO from './test-run-dto.js'
import TestSuiteDTO from './test-suite-dto.js'
import JobRecordDTO from './job-record-dto.js'
import JobRunDTO from './job-run-dto.js'
import JobTemplateDTO from './job-template-dto.js'
import JobResultDTO from './job-result-dto.js'
import JobNotificationDTO from './job-notification-dto.js'

/**
 * Client for Bespoken testing, training and monitoring API
 */
class Client {
  /**
   * Creates a new client for interacting with the Bespoken Test API
   * Must pass the API key which can be found in the Bespoken Dashboard under account settings
   * @param {string} apiKey
   * @param {string} [organizationId]
   * @param {string} [baseURL]
   */
  constructor (apiKey, organizationId, baseURL) {
    this.apiKey = apiKey
    this.baseURL = baseURL || 'https://test-api.bespoken.io/api'
    this.organizationId = organizationId
    if (typeof window !== 'undefined' && window.location.href.includes('localhost')) {
      this.baseURL = 'http://localhost:3000/api'
    }
    /** @private @type {TestResultCallback | undefined} */
    this._testResultCallback = undefined

    // We keep track of the results between calls - if there are changes, we notify the caller if the testResultCallback is registered
    /** @private @type {TestResultDTO[] | undefined} */
    this._lastResults = undefined
  }

  /**
   * Saves a job
   * * Creates a new job if none exists
   * * Updates if it is already existing
   * @param {string} resultID
   * @param {'ACCEPT' | 'DISCARD' | 'OVERRIDE'} status
   * @returns {Promise<void>}
   */
  async annotateResult (resultID, status) {
    const url = `${this.baseURL}/job-result/${resultID}/annotate/${status}?${this._queryString()}`
    await axios.get(url)
  }

  /**
   * Classifies the specified run
   * @param {string} runID
   * @returns {Promise<void>}
   */
  async classifyRun (runID) {
    const runURL = `${this.baseURL}/job-run/${runID}/classify`
    await axios.get(runURL)
  }

  /**
   * Creates a new device - must be one of the types allowed by the platform parameter
   * @param {any} deviceInfo
   * @returns {Promise<DeviceDTO>}
   */
  async saveDevice (deviceInfo) {
    const devicesURL = `${this.baseURL}/device?${this._queryString()}`
    let response
    try {
      console.info('create device url: ' + devicesURL)
      response = await axios.post(devicesURL, deviceInfo)
    } catch (e) {
      console.info('error: ' + e.status)
      if (e.message.includes('401')) {
        throw new Error('Invalid API key')
      }
      throw e
    }

    return new DeviceDTO(response.data)
  }

  /**
   * Deletes the specified device
   * @param {string} deviceToken
   * @returns {Promise<boolean>}
   */
  async deleteDevice (deviceToken) {
    const devicesURL = `${this.baseURL}/device/${deviceToken}?${this._queryString()}`
    try {
      console.info('fetch devices url: ' + devicesURL)
      await axios.delete(devicesURL)
    } catch (e) {
      console.info('error: ' + e.status)
      if (e.message.includes('401')) {
        throw new Error('Invalid API key')
      } else if (e.message.includes('404')) {
        return false
      }
      throw e
    }

    return true
  }

  /**
   * Deletes the specified notification
   * @param {string} jobID
   * @param {string} notificationID
   * @returns {Promise<boolean>}
   */
  async deleteJobNotification (jobID, notificationID) {
    const deleteURL = `${this.baseURL}/job/${jobID}/notification/${notificationID}?${this._queryString()}`

    try {
      console.info('delete notification url: ' + deleteURL)
      await axios.delete(deleteURL)
    } catch (e) {
      if (e.message.includes('401')) {
        throw new Error('Invalid API key')
      } else if (e.message.includes('404')) {
        return false
      }
      throw e
    }
    return true
  }

  /**
   * Deletes the specified notification
   * @param {string} jobID
   * @param {string} recordID
   * @returns {Promise<boolean>}
   */
  async deleteJobRecord (jobID, recordID) {
    const deleteURL = `${this.baseURL}/job/${jobID}/record/${recordID}?${this._queryString()}`

    try {
      console.info('delete record url: ' + deleteURL)
      await axios.delete(deleteURL)
    } catch (e) {
      if (e.message.includes('401')) {
        throw new Error('Invalid API key')
      } else if (e.message.includes('404')) {
        return false
      }
      throw e
    }
    return true
  }

  /**
   * Deletes the specified run
   * @param {string} runID
   * @returns {Promise<boolean>}
   */
  async deleteRun (runID) {
    const deleteRunURL = `${this.baseURL}/job-run/${runID}?${this._queryString()}`

    try {
      console.info('delete run url: ' + deleteRunURL)
      await axios.delete(deleteRunURL)
    } catch (e) {
      console.info('error: ' + e.status)
      if (e.message.includes('401')) {
        throw new Error('Invalid API key')
      } else if (e.message.includes('404')) {
        return false
      }
      throw e
    }
    return true
  }

  /**
   * Translates a cron schedule to readable english
   * @param {string} cron
   * @returns {Promise<string>}
   */
  async explainCron (cron) {
    const cronURL = `${this.baseURL}/job/${cron}/explain`
    const response = await axios.get(cronURL)
    return response.data
  }

  /**
   * Fetches information about the named device
   * @param {string} deviceToken
   * @returns {Promise<DeviceDTO | undefined>}
   */
  async fetchDevice (deviceToken) {
    const devicesURL = `${this.baseURL}/device/${deviceToken}?${this._queryString()}`
    let response
    try {
      console.info('fetch devices url: ' + devicesURL)
      response = await axios.get(devicesURL)
    } catch (e) {
      console.info('error: ' + e.status)
      if (e.message.includes('401')) {
        throw new Error('Invalid API key')
      } else if (e.message.includes('404')) {
        return undefined
      }
      throw e
    }
    // console.info('data: ' + JSON.stringify(response.data, null, 2))
    return new DeviceDTO(response.data)
  }

  /**
   * Fetches all devices associated with the account
   * @returns {Promise<DeviceDTO[]>}
   */
  async fetchDevices () {
    const devicesURL = `${this.baseURL}/device?${this._queryString()}`
    let response
    try {
      console.info('fetch devices url: ' + devicesURL)
      response = await axios.get(devicesURL)
    } catch (e) {
      console.info('error: ' + e.status)
      if (e.message.includes('401')) {
        throw new Error('Invalid API key')
      }
      throw e
    }
    // console.info('data: ' + JSON.stringify(response.data, null, 2))
    return response.data.devices.map(d => new DeviceDTO(d))
  }

  /**
   * Retrieves the named test suite
   * @param {string} testSuiteName
   * @returns {Promise<TestSuiteDTO>}
   */
  async fetchTestSuite (testSuiteName) {
    const testSuiteURL = `${this.baseURL}/test-suite/${testSuiteName}?${this._queryString()}`
    let response
    try {
      response = await axios.get(testSuiteURL)
    } catch (e) {
      if (e.response.status === 401) {
        throw new Error('Invalid API key')
      }
      throw e
    }
    // console.info('data: ' + JSON.stringify(response.data, null, 2))
    return TestSuiteDTO.fromJSON(response.data)
  }

  /**
   * @param {string} id
   * @returns {Promise<JobDTO | undefined>}
   */
  async fetchJobByID (id) {
    try {
      const response = await axios.get(`${this.baseURL}/job/${id}?${this._queryString()}`)
      return new JobDTO(response.data)
    } catch (e) {
      if (e.response.status === 404) {
        return undefined
      }
      throw e
    }
  }

  /**
   * @param {string} id
   * @returns {Promise<JobResultDTO | undefined>}
   */
  async fetchJobResultByID (id) {
    try {
      const response = await axios.get(`${this.baseURL}/job-result/${id}?${this._queryString()}`)
      return new JobResultDTO(response.data)
    } catch (e) {
      if (e.response.status === 404) {
        return undefined
      }
      throw e
    }
  }

  /**
   * @param {string} id
   * @returns {Promise<JobRunDTO[]>}
   */
  async fetchJobRunsByJobID (id) {
    try {
      const response = await axios.get(`${this.baseURL}/job/${id}/run?${this._queryString()}`)
      return response.data.map(o => new JobRunDTO(o))
    } catch (e) {
      if (e.response.status === 404) {
        return []
      }
      throw e
    }
  }

  /**
   * @param {string} organizationID
   * @returns {Promise<JobDTO[]>}
   */
  async fetchJobs (organizationID) {
    const response = await axios.get(`${this.baseURL}/job?organization-id=${organizationID}`)
    return response.data.map(o => new JobDTO(o))
  }

  /**
   * @returns {Promise<JobTemplateDTO[]>}
   */
  async fetchTemplates () {
    const response = await axios.get(`${this.baseURL}/job-template`)
    return response.data.map(o => new JobTemplateDTO(o))
  }

  /**
   * @returns {Promise<string>}
   */
  async generateID () {
    const response = await axios.get(`${this.baseURL}/id`)
    return response.data
  }

  /**
   * Callback to receive results from a test run as they occur
   * @param {TestResultCallback} callback
   * @returns {void}
   */
  onTestResult (callback) {
    this._testResultCallback = callback
  }

  /**
   * Resumes the specified run
   * @param {string} runID
   * @returns {Promise<JobRunDTO>}
   */
  async resumeRun (runID) {
    const runURL = `${this.baseURL}/job-run/${runID}/resume`
    const response = await axios.get(runURL)
    return new JobRunDTO(response.data)
  }

  /**
   * Runs the named test suite
   * Optionalls includes variables to be set as passed to the test suite
   * @param {string} testSuiteName
   * @param {string | undefined} testName
   * @param {Object<string, string>} [variables = {}]
   * @param {Object<string, string>} [configuration = {}]
   * @returns {Promise<TestResultDTO[]>}
   */
  async runTest (testSuiteName, testName, variables = {}, configuration = {}) {
    let runURL
    if (testName) {
      runURL = `${this.baseURL}/test-suite/${testSuiteName}/test/${testName}/run?${this._queryString()}`
    } else {
      runURL = `${this.baseURL}/test-suite/${testSuiteName}/run?${this._queryString()}`
    }
    for (const key of Object.keys(configuration)) {
      runURL += `&${key}=${configuration[key]}`
    }

    let response
    try {
      response = await axios.post(runURL, variables)
    } catch (e) {
      console.info('error: ' + e.response.status)
      if (e.message.includes('401')) {
        throw new Error('Invalid API key')
      }
      throw e
    }
    const runID = response.data.id
    let status = response.data.status

    const statusURL = `${this.baseURL}/test-run/${runID}?${this._queryString()}`
    let runStatusResponse
    let consecutiveErrors = 0
    while (status === 'IN_PROGRESS') {
      await this._sleep(1000)
      try {
        runStatusResponse = await axios.get(statusURL)
        status = runStatusResponse.data.status
        consecutiveErrors = 0
      } catch (e) {
        consecutiveErrors++
        if (consecutiveErrors >= 3) {
          console.error('Error checking results. Retries finished and giving up.')
          throw e
        } else {
          console.warn('Error checking results. Trying again. Attempts so far: ' + consecutiveErrors)
        }
      }
    }
    const results = runStatusResponse?.data.results.map(r => new TestResultDTO(r))
    return results
  }

  /**
   * Returns the status of the job run
   * @param {string} runID
   * @param {boolean} [includeResults = true]
   * @returns {Promise<JobRunDTO>}
   */
  async runStatus (runID, includeResults = true) {
    const runURL = `${this.baseURL}/job-run/${runID}?include-results=${includeResults}`
    const response = await axios.get(runURL)
    return new JobRunDTO(response.data)
  }

  /**
   * Saves a job
   * * Creates a new job if none exists
   * * Updates if it is already existing
   * @param {any} json
   * @returns {Promise<JobDTO>}
   */
  async saveJob (json) {
    const url = `${this.baseURL}/job?${this._queryString()}`
    const response = await axios.post(url, json)
    return new JobDTO(response.data)
  }

  /**
   * Deletes the specified job
   * @param {string} id
   * @returns {Promise<boolean>}
   */
  async deleteJob (id) {
    const devicesURL = `${this.baseURL}/job/${id}?${this._queryString()}`
    try {
      console.info('delete job url: ' + devicesURL)
      await axios.delete(devicesURL)
    } catch (e) {
      console.info('error: ' + e.status)
      if (e.message.includes('401')) {
        throw new Error('Invalid API key')
      } else if (e.message.includes('404')) {
        return false
      }
      throw e
    }

    return true
  }

  /**
   * @param {string} jobID
   * @param {JobNotificationDTO} notification
   * @returns {Promise<void>}
   */
  async saveNotification (jobID, notification) {
    const url = `${this.baseURL}/job/${jobID}/notification?${this._queryString()}`
    await axios.post(url, notification.json)
  }

  /**
   * Saves a record
   * * Creates a new test suite if none exists
   * * Updates if it is already existing
   * @param {string} jobID
   * @param {JobRecordDTO} record
   * @returns {Promise<void>}
   */
  async saveRecord (jobID, record) {
    const url = `${this.baseURL}/job/${jobID}/record?${this._queryString()}`
    await axios.post(url, record.json)
  }

  /**
   * Saves a test suite
   * * Creates a new test suite if none exists
   * * Updates if it is already existing
   * @param {TestSuiteDTO} testSuite
   * @returns {Promise<void>}
   */
  async saveTestSuite (testSuite) {
    const url = `${this.baseURL}/test-suite?${this._queryString()}`
    await axios.post(url, testSuite)
  }

  /**
   * Runs the specified job, returns a unique ID to track the status of the run
   * @param {string} jobID
   * @returns {Promise<JobRunDTO>}
   */
  async startJob (jobID) {
    const runURL = `${this.baseURL}/job/${jobID}/start`
    const response = await axios.get(runURL)
    return new JobRunDTO(response.data)
  }

  /**
   * Stops the specified run
   * @param {string} runID
   * @returns {Promise<void>}
   */
  async stopRun (runID) {
    const stopURL = `${this.baseURL}/job-run/${runID}/stop`
    await axios.get(stopURL)
  }

  /**
   * Checks the status of a test run
   * @param {string} testRunID
   * @returns {Promise<TestRunDTO>}
   */
  async testStatus (testRunID) {
    const url = `${this.baseURL}/test-run/${testRunID}?${this._queryString()}`
    const response = await axios.get(url)
    return new TestRunDTO(response.data)
  }

  /**
   * @returns {string}
   */
  _queryString () {
    let apiKey = `api-key=${this.apiKey}`
    if (this.organizationId) apiKey += `&org-id=${this.organizationId}`
    return apiKey
  }

  /**
   * @private
   * @param {TestResultDTO[]} results
   * @returns {void}
   */
  _triggerTestResultCallback (results) {
    if (!this._testResultCallback) {
      return
    }

    if (!this._lastResults) {
      this._testResultCallback(results)
      this._lastResults = _.cloneDeep(results)
      return
    }

    // If the number of results are different, trigger the callback
    if (results.length > this._lastResults.length) {
      this._testResultCallback(results)
      this._lastResults = _.cloneDeep(results)
      return
    }

    // If the number of interactions for a test are different, trigger the callback
    for (let i = 0; i < results.length; i++) {
      const newResult = results[i]
      const oldResult = this._lastResults[i]

      console.info('new result: ' + JSON.stringify(newResult))
      console.info('old result: ' + JSON.stringify(oldResult))
      if (newResult.interactions.length > oldResult.interactions.length) {
        this._testResultCallback(results)
        this._lastResults = _.cloneDeep(results)
        return
      }
    }
  }

  /**
   * @private
   * @param {number} time
   * @returns {Promise<void>}
   */
  async _sleep (time) {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve()
      }, time)
    })
  }
}

/**
 * Callback function to receive test results as they happen while a test is being run
 * @callback TestResultCallback
 * @param {TestResultDTO[]} testResults
 */
export default Client