├── .github └── workflows │ └── test.yaml ├── .gitignore ├── .vscode └── tasks.json ├── LICENSE ├── README.md ├── babel.config.js ├── package.json ├── prettier.config.js ├── src ├── errors.js ├── graphql.js ├── queries.js ├── repeater.js ├── types │ ├── application.js │ ├── job.js │ ├── jobResult.js │ ├── runner.js │ └── type.js └── utility.js ├── test ├── errors.test.js ├── graphql.test.js ├── mockedResponses.js ├── queries.test.js ├── repeater.test.js ├── testHelper.js ├── types │ ├── application.test.js │ ├── job.test.js │ ├── jobResult.test.js │ ├── runner.test.js │ └── type.test.js └── utility.test.js └── yarn.lock /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | types: [opened, synchronize, reopened] 7 | jobs: 8 | all: 9 | strategy: 10 | matrix: 11 | os: ['ubuntu-latest', 'windows-latest'] 12 | node-version: ['14', '12'] 13 | fail-fast: true 14 | runs-on: ${{ matrix.os }} 15 | name: ${{ matrix.os }} | Node ${{ matrix.node-version }} latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Setup node 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Cache "node_modules" 23 | uses: actions/cache@v2 24 | if: matrix.os == 'ubuntu-latest' 25 | with: 26 | path: '**/node_modules' 27 | key: node_modules_${{ runner.os }}_${{ hashFiles('**/yarn.lock') }} 28 | - name: Install dependencies 29 | run: yarn install --frozen-lockfile 30 | - name: Run tests 31 | run: yarn test 32 | env: 33 | CI: true 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "test -- ${file}", 7 | "group": "test", 8 | "problemMatcher": [], 9 | "label": "npm: test", 10 | "detail": "jest" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Redwood 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 | # repeaterdev-js 2 | 3 | The official Javascript library for accessing the https://repeater.dev API. 4 | 5 | Repeater.dev makes it easy for Jamstack applications to perform asynchronous/background 6 | and recurring job processing. Need to send an email 24 hours after signup? Create a 7 | serverless function that sends that email, then have Repeater issue an HTTP call to 8 | that function in 24 hours. 9 | 10 | ## Prequisites 11 | 12 | You'll need an account at https://repeater.dev and at least one [Application](https://docs.repeater.dev/#getting-started). 13 | 14 | ## Installation 15 | 16 | yarn add repeaterdev-js 17 | 18 | Or 19 | 20 | npm install repeaterdev-js 21 | 22 | ## Usage 23 | 24 | ```javascript 25 | import { Repeater } from 'repeaterdev-js' 26 | 27 | // or 28 | 29 | const { Repeater } = require('repeaterdev-js') 30 | ``` 31 | 32 | Initialize `Repeater` with an [Application Token](https://docs.repeater.dev/#getting-started) 33 | and get back an instance that you'll use to make all calls: 34 | 35 | ```javascript 36 | const repeater = new Repeater('8ac0be4c06836527b63543ca70a84cb5') 37 | ``` 38 | 39 | ### Enqueuing a Job 40 | 41 | You can enqueue [jobs](https://docs.repeater.dev/#jobs) and tell them when to run. If you 42 | leave `runAt` blank then the job will run as soon as possible: 43 | 44 | ```javascript 45 | repeater.enqueue({ 46 | name: 'sample-job', 47 | endpoint: 'https://mysite.com/api/sample', 48 | verb: 'POST' 49 | }).then(job => { 50 | console.log(job) 51 | }) 52 | 53 | // or 54 | 55 | const job = await repeater.enqueue({ 56 | name: 'sample-job', 57 | endpoint: 'https://mysite.com/api/sample', 58 | verb: 'POST' 59 | }) 60 | console.log(job) 61 | ``` 62 | 63 | In the example above the call to `enqueue` will be a Promise that resolves once the job is successfully 64 | enqueued. Note the actual running of the job is asynchronous—you will need to query separately 65 | to check on the status of an existing job (see [Retrieving Job Results](#retrieving-jobresults)). 66 | 67 | In the above example, when the job runs, Repeater will issue an HTTP POST request to `https://mysite.com/api/sample` and record the [result](#retrieving-jobresults). 68 | 69 | #### Parameter Notes 70 | 71 | For convenience, the `headers` property can be set as a JSON object. It will automatically be serialized to a string for you. 72 | 73 | `body` should be set as a string, but if you use the `json` key instead then the values will be serialized to a string automatically, and a `Content-Type: application/json` header will be added to `headers`: 74 | 75 | ```javascript 76 | const job = await repeater.enqueue({ 77 | name: 'sample-job', 78 | endpoint: 'https://mysite.com/api/sample', 79 | verb: 'POST', 80 | headers: { 'Authorization': 'Bearer ABCD1234' }, 81 | json: { data: { user: { id: 434 } } } 82 | }) 83 | 84 | // variables set on GraphQL call become: 85 | 86 | { 87 | name: "sample-job", 88 | endpoint: "https://mysite.com/api/sample", 89 | verb: "POST", 90 | headers: "{\"Authorization\":\"Bearer ABCD1234\",\"Content-Type\":\"application/json\"}", 91 | body: "{\"data\":{\"user\":{\"id\":434}}}" 92 | } 93 | ``` 94 | 95 | `runAt` should be a Javascript Date. It will be converted to UTC before the job is enqueued. 96 | If you don't specify a `runAt` when calling `enqueue()` then the job will be set to run now, 97 | meaning as soon as the Repeater.dev processing queue can get to it. 98 | 99 | By default, `enabled` and `retryable` are set to `true`. 100 | 101 | ### Listing Existing Jobs 102 | 103 | Return all currently available jobs for the application: 104 | 105 | ```javascript 106 | repeater.jobs().then(jobs => { 107 | console.log(jobs) 108 | }) 109 | 110 | // or 111 | 112 | const jobs = await repeater.jobs() 113 | console.log(jobs) 114 | ``` 115 | 116 | ### Retrieving a Single Job 117 | 118 | Return a single job by name: 119 | 120 | ```javascript 121 | repeater.job('job-name').then(job => { 122 | console.log(job) 123 | }) 124 | 125 | // or 126 | 127 | const job = await repeater.job('job-name') 128 | console.log(job) 129 | ``` 130 | 131 | ### Retrieving JobResults 132 | 133 | You can check on the [results](https://docs.repeater.dev/#jobresults) of any jobs that have run 134 | by calling `results()` an an instance of a job: 135 | 136 | ```javascript 137 | repeater.job('sample-job') 138 | .then(job => job.results()) 139 | .then(results => console.log(job.results)) 140 | 141 | // or 142 | 143 | const job = await repeater.job('sample-job') 144 | const results = await job.results() 145 | console.log(results) 146 | ``` 147 | 148 | `results` will be an array with one member for each time the job has run. 149 | 150 | ### Editing a Job 151 | 152 | First we get the existing job details by using the job's name and then update that 153 | job once the Promise resolves: 154 | 155 | ```javascript 156 | repeater.job('sample-job').then(job => { 157 | job.update({ runAt: '2022-01-01T12:00:00Z' }) 158 | }) 159 | 160 | // or 161 | 162 | const job = await repeater.job('sample-job') 163 | await job.update({ runAt: '2022-01-01T12:00:00Z' }) 164 | ``` 165 | 166 | When updating a job, any pending job runs are canceled and rescheduled 167 | (if the job is `enabled`) based on the values in `runAt` and `runEvery`. 168 | 169 | After running, the job instance will be updated with the new value(s) that were 170 | just saved: 171 | 172 | ```javascript 173 | const job = await repeater.job('sample-job') 174 | job.verb // => 'GET' 175 | await job.update({ verb: 'POST' }) 176 | job.verb // => 'POST' 177 | ``` 178 | 179 | > Note that you cannot rename an existing job. If you really need to give a job 180 | > a new name you'll need to delete the existing job and create a new one. 181 | 182 | ### Enqueuing or Updating a Job 183 | 184 | Sometimes you have a job you want to enqueue but only if it isn't already 185 | enqueued. And if it is, you want to update it to the latest settings. That's where 186 | `enqueueOrUpdate()` comes into play: 187 | 188 | ```javascript 189 | const job = await repeater.enqueueOrUpdate({ 190 | name: 'sample-job', 191 | endpoint: 'https://mysite.com/api/sample', 192 | verb: 'post' 193 | }) 194 | ``` 195 | 196 | In this example, if a job named `sample-job` already exists then this call would 197 | be the equivalent of: 198 | 199 | ```javascript 200 | const job = await repeater.job('sample-job') 201 | await job.update({ 202 | endpoint: 'https://mysite.com/api/sample', 203 | verb: 'post' 204 | }) 205 | ``` 206 | 207 | If the job named `sample-job` does not exist, then the call would be equivalent to: 208 | 209 | ```javascript 210 | const job = await repeater.enqueue({ 211 | name: 'sample-job', 212 | endpoint: 'https://mysite.com/api/sample', 213 | verb: 'post' 214 | }) 215 | ``` 216 | 217 | ### Deleting a Job 218 | 219 | First look up the job by name and then issue the delete: 220 | 221 | ```javascript 222 | repeater.job('sample-job').then(job => { 223 | job.delete() 224 | }) 225 | 226 | // or 227 | 228 | const job = await repeater.job('sample-job') 229 | await job.delete() 230 | ``` 231 | 232 | You can tell if a Job instance represents a deleted job by checking 233 | the `isDeleted` property: 234 | 235 | ```javascript 236 | const job = await repeater.job('sample-job') 237 | job.isDeleted // => false 238 | await job.delete() 239 | job.isDeleted // => true 240 | ``` 241 | 242 | Once a job has been deleted, calls to `update()`, `delete()` or `results()` will throw 243 | a `ReadOnlyError`. 244 | 245 | ## Bug Reports 246 | 247 | Open an [issue](https://github.com/redwoodjs/repeaterdev-js/issues)! 248 | 249 | ## Contributing 250 | 251 | Open a [pull request](https://github.com/redwoodjs/repeaterdev-js/pulls)! 252 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const TARGETS_NODE = '12.13.0' 2 | const CORE_JS_VERSION = '3.6' 3 | 4 | module.exports = { 5 | presets: [ 6 | [ 7 | '@babel/preset-env', 8 | { 9 | targets: { node: TARGETS_NODE }, 10 | useBuiltIns: 'usage', 11 | corejs: { 12 | version: CORE_JS_VERSION, 13 | // List of supported proposals: https://github.com/zloirock/core-js/blob/master/docs/2019-03-19-core-js-3-babel-and-a-look-into-the-future.md#ecmascript-proposals 14 | proposals: true, 15 | }, 16 | }, 17 | ], 18 | ], 19 | plugins: [ 20 | ['@babel/plugin-proposal-class-properties', { loose: true }], 21 | [ 22 | '@babel/plugin-transform-runtime', 23 | { 24 | // https://babeljs.io/docs/en/babel-plugin-transform-runtime/#core-js-aliasing 25 | // Setting the version here also requires `@babel/runtime-corejs3` 26 | corejs: { version: 3, proposals: true }, 27 | // https://babeljs.io/docs/en/babel-plugin-transform-runtime/#version 28 | // Transform-runtime assumes that @babel/runtime@7.0.0 is installed. 29 | // Specifying the version can result in a smaller bundle size. 30 | // TODO: Grab version for package.json 31 | version: '^7.11.2', 32 | }, 33 | ], 34 | ] 35 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repeaterdev-js", 3 | "version": "0.7.2", 4 | "description": "Official Javascript library for Repeater.dev", 5 | "main": "dist/repeater.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "build": "yarn babel src -d dist", 11 | "test": "jest" 12 | }, 13 | "repository": "https://github.com/redwoodjs/repeaterdev-js", 14 | "keywords": [ 15 | "repeater", 16 | "jobs", 17 | "background", 18 | "scheduled", 19 | "async", 20 | "recurring" 21 | ], 22 | "author": "Rob Cameron", 23 | "license": "MIT", 24 | "dependencies": { 25 | "graphql": "^15.3.0", 26 | "graphql-request": "^3.3.0", 27 | "iso8601-duration": "^1.2.0", 28 | "@babel/runtime-corejs3": "^7.11.2" 29 | }, 30 | "devDependencies": { 31 | "@babel/cli": "^7.11.6", 32 | "@babel/core": "^7.11.6", 33 | "@babel/node": "^7.10.5", 34 | "@babel/plugin-proposal-class-properties": "^7.10.4", 35 | "@babel/plugin-transform-runtime": "^7.11.5", 36 | "@babel/preset-env": "^7.11.5", 37 | "babel-jest": "^26.3.0", 38 | "core-js": "^3.6.5", 39 | "jest": "^26.4.2", 40 | "mockdate": "^3.0.2", 41 | "msw": "^0.21.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // https://prettier.io/docs/en/options.html 2 | module.exports = { 3 | trailingComma: 'es5', 4 | bracketSpacing: true, 5 | tabWidth: 2, 6 | semi: false, 7 | singleQuote: true, 8 | arrowParens: 'always', 9 | } 10 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | export const repeaterErrorPrefix = '' 2 | 3 | export class RepeaterError extends Error { 4 | constructor(message) { 5 | super(`${repeaterErrorPrefix}${message}`) 6 | this.name = 'RepeaterError' 7 | } 8 | } 9 | 10 | export class ReadOnlyError extends RepeaterError { 11 | constructor() { 12 | super('Job has been deleted and is read-only') 13 | this.name = 'ReadOnlyError' 14 | } 15 | } 16 | 17 | export class GraphQLError extends RepeaterError { 18 | constructor() { 19 | super('GraphQL Error: ') 20 | this.name = 'GraphQLError' 21 | } 22 | } 23 | 24 | export class ParameterError extends RepeaterError { 25 | constructor(field, message) { 26 | super(`Parameter error: ${field} ${message}`) 27 | this.name = 'ParameterError' 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/graphql.js: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request' 2 | 3 | export const graphQLClient = (token, options) => { 4 | return new GraphQLClient(options.endpoint, { 5 | headers: { 6 | authorization: `Bearer ${token}`, 7 | }, 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /src/queries.js: -------------------------------------------------------------------------------- 1 | export const jobs = ` 2 | query JobsQuery { 3 | jobs { 4 | name 5 | enabled 6 | body 7 | endpoint 8 | verb 9 | headers 10 | body 11 | retryable 12 | runAt 13 | runEvery 14 | createdAt 15 | updatedAt 16 | lastRunAt 17 | nextRunAt 18 | } 19 | } 20 | ` 21 | 22 | export const job = ` 23 | query JobQuery($name: String!) { 24 | job(name: $name) { 25 | name 26 | enabled 27 | body 28 | endpoint 29 | verb 30 | headers 31 | body 32 | retryable 33 | runAt 34 | runEvery 35 | createdAt 36 | updatedAt 37 | lastRunAt 38 | nextRunAt 39 | } 40 | } 41 | ` 42 | 43 | export const create = ` 44 | mutation CreateJobMutation( 45 | $name: String!, 46 | $enabled: Boolean!, 47 | $endpoint: String!, 48 | $verb: String!, 49 | $headers: String, 50 | $body: String, 51 | $retryable: Boolean! 52 | $runAt: String!, 53 | $runEvery: String) { 54 | createJob( 55 | name: $name, 56 | enabled: $enabled, 57 | endpoint: $endpoint, 58 | verb: $verb 59 | headers: $headers, 60 | body: $body, 61 | retryable: $retryable, 62 | runAt: $runAt, 63 | runEvery: $runEvery 64 | ) { 65 | name 66 | enabled 67 | body 68 | endpoint 69 | verb 70 | headers 71 | body 72 | retryable 73 | runAt 74 | runEvery 75 | createdAt 76 | updatedAt 77 | } 78 | } 79 | ` 80 | 81 | export const update = ` 82 | mutation UpdateJobMutation( 83 | $name: String!, 84 | $enabled: Boolean, 85 | $endpoint: String, 86 | $verb: String, 87 | $headers: String, 88 | $body: String, 89 | $retryable: Boolean 90 | $runAt: String, 91 | $runEvery: String) { 92 | updateJob( 93 | name: $name, 94 | enabled: $enabled, 95 | endpoint: $endpoint, 96 | verb: $verb, 97 | headers: $headers, 98 | body: $body, 99 | retryable: $retryable, 100 | runAt: $runAt, 101 | runEvery: $runEvery 102 | ) { 103 | name 104 | enabled 105 | body 106 | endpoint 107 | verb 108 | headers 109 | body 110 | retryable 111 | runAt 112 | runEvery 113 | createdAt 114 | updatedAt 115 | } 116 | } 117 | ` 118 | 119 | export const destroy = ` 120 | mutation DeleteJobMutation($name: String!) { 121 | deleteJob(name: $name) { 122 | name 123 | } 124 | } 125 | ` 126 | 127 | export const results = ` 128 | query JobResultsQuery($jobName: String!) { 129 | jobResults(jobName: $jobName) { 130 | status 131 | headers 132 | body 133 | runAt 134 | run 135 | duration 136 | createdAt 137 | updatedAt 138 | } 139 | } 140 | ` 141 | -------------------------------------------------------------------------------- /src/repeater.js: -------------------------------------------------------------------------------- 1 | import { graphQLClient } from './graphql' 2 | import { 3 | create as createQuery, 4 | jobs as jobsQuery, 5 | job as jobQuery, 6 | } from './queries' 7 | import { GraphQLError, ParameterError } from './errors' 8 | import { merge, normalizeParams } from './utility' 9 | import Job from './types/job' 10 | 11 | export const API_ENDPOINT = 'https://api.repeater.dev/graphql' 12 | 13 | export const VERBS = [ 14 | 'GET', 15 | 'POST', 16 | 'PUT', 17 | 'PATCH', 18 | 'DELETE', 19 | 'HEAD', 20 | 'OPTIONS', 21 | ] 22 | 23 | export const requiredParams = { 24 | token: { 25 | required: 'is required', 26 | }, 27 | name: { 28 | required: 'is required', 29 | }, 30 | verb: { 31 | required: `must be one of ${VERBS.join(' | ')}`, 32 | }, 33 | endpoint: { 34 | required: 'is required', 35 | format: 'must look like a URL', 36 | }, 37 | runAt: { 38 | format: 'must be a Date', 39 | }, 40 | runEvery: { 41 | format: 'must be an ISO8601 Duration string', 42 | }, 43 | } 44 | 45 | const DEFAULT_OPTIONS = { 46 | endpoint: API_ENDPOINT, 47 | } 48 | 49 | export class Repeater { 50 | constructor(token, options = {}) { 51 | this.setToken(token) 52 | this.setOptions(options) 53 | this._initClient() 54 | } 55 | 56 | async enqueue(params = {}) { 57 | const defaultVariables = { 58 | enabled: true, 59 | retryable: true, 60 | runAt: new Date(), 61 | } 62 | const variables = normalizeParams(merge(defaultVariables, params)) 63 | 64 | try { 65 | const data = await this.client.request(createQuery, variables) 66 | return new Job(data.createJob, { 67 | token: this._token, 68 | ...this._options, 69 | }) 70 | } catch (error) { 71 | throw new GraphQLError(error.message) 72 | } 73 | } 74 | 75 | async enqueueOrUpdate(params = {}) { 76 | const job = await this.job(params.name) 77 | 78 | if (job) { 79 | return await job.update(params) 80 | } else { 81 | return await this.enqueue(params) 82 | } 83 | } 84 | 85 | async jobs() { 86 | try { 87 | const data = await this.client.request(jobsQuery) 88 | return data.jobs.map((job) => { 89 | return new Job(job, { token: this._token, ...this._options }) 90 | }) 91 | } catch (error) { 92 | throw new GraphQLError(error.message) 93 | } 94 | } 95 | 96 | async job(name) { 97 | try { 98 | const data = await this.client.request(jobQuery, { name }) 99 | if (data.job) { 100 | return new Job(data.job, { token: this._token, ...this._options }) 101 | } else { 102 | return null 103 | } 104 | } catch (error) { 105 | throw new GraphQLError(error.message) 106 | } 107 | } 108 | 109 | setToken(token) { 110 | if (!token) throw new ParameterError('token', requiredParams.token.required) 111 | 112 | this._token = token 113 | } 114 | 115 | setOptions(options) { 116 | this._options = merge(DEFAULT_OPTIONS, options) 117 | } 118 | 119 | _initClient() { 120 | this.client = graphQLClient(this._token, this._options) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/types/application.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redwoodjs/repeaterdev-js/228525feef3a0cd68a1d0f526b561833d7122e5a/src/types/application.js -------------------------------------------------------------------------------- /src/types/job.js: -------------------------------------------------------------------------------- 1 | import { 2 | update as updateQuery, 3 | destroy as deleteQuery, 4 | results as resultsQuery, 5 | } from '../queries' 6 | import { GraphQLError, ReadOnlyError } from '../errors' 7 | import { normalizeParams } from '../utility' 8 | import Type from './type' 9 | import JobResult from './jobResult' 10 | 11 | export default class Job extends Type { 12 | parse(data) { 13 | this.name = data.name || this.name 14 | this.enabled = data.enabled || this.enabled 15 | this.body = data.body || this.body 16 | this.endpoint = data.hasOwnProperty('endpoint') 17 | ? data.endpoint 18 | : this.endpoint 19 | this.verb = data.verb || this.verb 20 | this.headers = data.headers ? JSON.parse(data.headers) : this.headers 21 | this.retryable = data.hasOwnProperty('retryable') 22 | ? data.retryable 23 | : this.retryable 24 | this.runAt = data.runAt ? new Date(data.runAt) : this.runAt || null 25 | this.runEvery = data.runEvery || this.runEvery 26 | this.createdAt = data.createdAt 27 | ? new Date(data.createdAt) 28 | : this.createdAt || null 29 | this.updatedAt = data.updatedAt 30 | ? new Date(data.updatedAt) 31 | : this.updatedAt || null 32 | this.lastRunAt = data.lastRunAt 33 | ? new Date(data.lastRunAt) 34 | : this.lastRunAt || null 35 | this.nextRunAt = data.nextRunAt 36 | ? new Date(data.nextRunAt) 37 | : this.nextRunAt || null 38 | } 39 | 40 | async update(params) { 41 | if (this.isDeleted) throw new ReadOnlyError() 42 | 43 | try { 44 | const data = await this.client.request( 45 | updateQuery, 46 | normalizeParams(Object.assign({ name: this.name }, params)) 47 | ) 48 | this.parse(data.updateJob) 49 | return this 50 | } catch (error) { 51 | throw new GraphQLError(error.message) 52 | } 53 | } 54 | 55 | async delete() { 56 | if (this.isDeleted) throw new ReadOnlyError() 57 | 58 | try { 59 | await this.client.request(deleteQuery, { name: this.name }) 60 | this.isDeleted = true 61 | return this 62 | } catch (error) { 63 | throw new GraphQLError(error.message) 64 | } 65 | } 66 | 67 | async results() { 68 | if (this.isDeleted) throw new ReadOnlyError() 69 | 70 | try { 71 | const data = await this.client.request(resultsQuery, { 72 | jobName: this.name, 73 | }) 74 | 75 | return data.jobResults.map( 76 | (result) => 77 | new JobResult(result, { 78 | token: this._token, 79 | jobName: this.name, 80 | ...this._options, 81 | }) 82 | ) 83 | } catch (error) { 84 | throw new GraphQLError(error.message) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/types/jobResult.js: -------------------------------------------------------------------------------- 1 | import { job as jobQuery } from '../queries' 2 | import { GraphQLError } from '../errors' 3 | import Type from './type' 4 | import Job from './job' 5 | 6 | export default class JobResult extends Type { 7 | constructor(data, { jobName, ...options }) { 8 | super(data, options) 9 | this._jobName = jobName 10 | } 11 | 12 | parse(data) { 13 | this.status = data.status 14 | this.headers = data.headers ? JSON.parse(data.headers) : null 15 | this.body = data.body 16 | this.runAt = data.runAt ? new Date(data.runAt) : null 17 | this.run = data.run 18 | this.duration = data.duration 19 | this.createdAt = data.createdAt ? new Date(data.createdAt) : null 20 | this.updatedAt = data.updatedAt ? new Date(data.updatedAt) : null 21 | } 22 | 23 | async job() { 24 | try { 25 | const data = await this.client.request(jobQuery, { 26 | name: this._jobName, 27 | }) 28 | return new Job(data.job, { token: this._token, ...this._options }) 29 | } catch (error) { 30 | throw new GraphQLError(error.message) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/types/runner.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redwoodjs/repeaterdev-js/228525feef3a0cd68a1d0f526b561833d7122e5a/src/types/runner.js -------------------------------------------------------------------------------- /src/types/type.js: -------------------------------------------------------------------------------- 1 | import { graphQLClient } from '../graphql' 2 | 3 | export default class Type { 4 | constructor(data, { token, ...options }) { 5 | this.setToken(token) 6 | this.setOptions(options) 7 | this.parse(data) 8 | this._initClient() 9 | this.isDeleted = false 10 | } 11 | 12 | setToken(token) { 13 | this._token = token 14 | } 15 | 16 | setOptions(options) { 17 | this._options = options 18 | } 19 | 20 | parse(data) { 21 | this.data = data 22 | } 23 | 24 | _initClient() { 25 | this.client = graphQLClient(this._token, this._options) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utility.js: -------------------------------------------------------------------------------- 1 | // Duplicates Ruby's merge where only non-null values from the second 2 | // object are merged into the first. 3 | export const merge = (obj1, obj2) => { 4 | let output = { ...obj1 } 5 | 6 | for (let key in obj2) { 7 | if (obj2[key] !== undefined && obj2[key] !== null) { 8 | output[key] = obj2[key] 9 | } 10 | } 11 | 12 | return output 13 | } 14 | 15 | export const normalizeParams = (params) => { 16 | const jsonHeader = { 'Content-Type': 'application/json' } 17 | 18 | const normalizedParams = params 19 | 20 | normalizedParams.verb = normalizedParams.verb?.toUpperCase() 21 | 22 | if (!normalizedParams.body) { 23 | if (params.json) { 24 | normalizedParams.body = JSON.stringify(params.json) 25 | normalizedParams.headers = normalizedParams.headers 26 | ? merge(normalizedParams.headers, jsonHeader) 27 | : jsonHeader 28 | } else { 29 | delete normalizedParams.body 30 | } 31 | } 32 | delete normalizedParams.json 33 | 34 | if (typeof normalizedParams.headers === 'object') { 35 | normalizedParams.headers = JSON.stringify(normalizedParams.headers) 36 | } 37 | 38 | return normalizedParams 39 | } 40 | -------------------------------------------------------------------------------- /test/errors.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | RepeaterError, 3 | ReadOnlyError, 4 | GraphQLError, 5 | ParameterError, 6 | } from '../src/errors' 7 | 8 | test('RepeaterError name is RepeaterError', () => { 9 | const error = new RepeaterError('Foobar') 10 | 11 | expect(error.name).toEqual('RepeaterError') 12 | }) 13 | 14 | test('RepeaterError contains no special text', () => { 15 | const error = new RepeaterError('Foobar') 16 | 17 | expect(error.message).toEqual('Foobar') 18 | }) 19 | 20 | test('ReadOnlyError name is ReadOnlyError', () => { 21 | const error = new ReadOnlyError('Foobar') 22 | 23 | expect(error.name).toEqual('ReadOnlyError') 24 | }) 25 | 26 | test('GraphQLError sets its own message', () => { 27 | const error = new GraphQLError('Foobar') 28 | 29 | expect(error.message).toMatch(/GraphQL Error/) 30 | expect(error.message).not.toMatch(/Foobar/) 31 | }) 32 | 33 | test('GraphQLError name is GraphQLError', () => { 34 | const error = new GraphQLError('Foobar') 35 | 36 | expect(error.name).toEqual('GraphQLError') 37 | }) 38 | 39 | test('ReadOnlyError sets its own message', () => { 40 | const error = new ReadOnlyError('Foobar') 41 | 42 | expect(error.message).toMatch(/read-only/) 43 | expect(error.message).not.toMatch(/Foobar/) 44 | }) 45 | 46 | test('ParameterError name is ParameterError', () => { 47 | const error = new ParameterError('Foobar') 48 | 49 | expect(error.name).toEqual('ParameterError') 50 | }) 51 | 52 | test('ParameterError prepends the error message with text', () => { 53 | const error = new ParameterError('Foobar', 'is nonsense') 54 | 55 | expect(error.message).toEqual('Parameter error: Foobar is nonsense') 56 | }) 57 | -------------------------------------------------------------------------------- /test/graphql.test.js: -------------------------------------------------------------------------------- 1 | test('truth', () => { 2 | expect(true).toEqual(true) 3 | }) 4 | -------------------------------------------------------------------------------- /test/mockedResponses.js: -------------------------------------------------------------------------------- 1 | import { graphql } from 'msw' 2 | import { setupServer } from 'msw/node' 3 | import { endpoint } from './testHelper' 4 | 5 | const localhost = graphql.link(endpoint) 6 | 7 | export const mswServer = setupServer() 8 | 9 | export const jobsResponse = localhost.query('JobsQuery', (req, res, ctx) => { 10 | return res( 11 | ctx.data({ 12 | jobs: [ 13 | { 14 | __typename: 'Job', 15 | name: 'test-job-1', 16 | enabled: true, 17 | body: 'foo=bar', 18 | endpoint: 'http://test.host/api/test', 19 | verb: 'GET', 20 | headers: '{"Content-Type":"text/plain"}', 21 | retryable: true, 22 | runAt: '2020-01-01T00:00:00Z', 23 | runEvery: null, 24 | createdAt: '2020-01-02T01:00:00Z', 25 | updatedAt: '2020-01-03T02:00:00Z', 26 | lastRunAt: null, 27 | nextRunAt: null, 28 | }, 29 | { 30 | __typename: 'Job', 31 | name: 'test-job-2', 32 | enabled: true, 33 | body: 'foo=bar', 34 | endpoint: 'http://test.host/api/test', 35 | verb: 'GET', 36 | headers: '{"Content-Type":"text/plain"}', 37 | retryable: true, 38 | runAt: '2020-02-01T00:00:00Z', 39 | runEvery: null, 40 | createdAt: '2020-02-02T01:00:00Z', 41 | updatedAt: '2020-02-03T02:00:00Z', 42 | lastRunAt: null, 43 | nextRunAt: null, 44 | }, 45 | ], 46 | }) 47 | ) 48 | }) 49 | 50 | export const singleJobResponse = localhost.query( 51 | 'JobQuery', 52 | (req, res, ctx) => { 53 | return res( 54 | ctx.data({ 55 | job: { 56 | __typename: 'Job', 57 | name: req.variables.name || 'test-job-1', 58 | enabled: true, 59 | body: 'foo=bar', 60 | endpoint: 'http://test.host/api/test', 61 | verb: 'GET', 62 | headers: '{"Content-Type":"text/plain"}', 63 | retryable: true, 64 | runAt: '2020-01-01T00:00:00Z', 65 | runEvery: null, 66 | createdAt: '2020-01-02T01:00:00Z', 67 | updatedAt: '2020-01-03T02:00:00Z', 68 | lastRunAt: null, 69 | nextRunAt: null, 70 | }, 71 | }) 72 | ) 73 | } 74 | ) 75 | 76 | export const nullJobResponse = localhost.query('JobQuery', (req, res, ctx) => { 77 | return res( 78 | ctx.data({ 79 | job: null, 80 | }) 81 | ) 82 | }) 83 | 84 | export const recurringJobResponse = localhost.query( 85 | 'JobQuery', 86 | (req, res, ctx) => { 87 | return res( 88 | ctx.data({ 89 | job: { 90 | __typename: 'Job', 91 | name: req.variables.name || 'test-job-1', 92 | enabled: true, 93 | body: 'foo=bar', 94 | endpoint: 'http://test.host/api/test', 95 | verb: 'GET', 96 | headers: '{"Content-Type":"text/plain"}', 97 | retryable: true, 98 | runAt: '2020-01-01T00:00:00Z', 99 | runEvery: 'P1H', 100 | createdAt: '2020-01-02T01:00:00Z', 101 | updatedAt: '2020-01-03T02:00:00Z', 102 | lastRunAt: '2020-01-04T03:00:00Z', 103 | nextRunAt: '2020-01-05T04:00:00Z', 104 | }, 105 | }) 106 | ) 107 | } 108 | ) 109 | 110 | export const jobErrorResponse = localhost.query('JobQuery', (req, res, ctx) => { 111 | return res( 112 | ctx.errors([ 113 | { 114 | message: 'Mocked error response', 115 | }, 116 | ]) 117 | ) 118 | }) 119 | 120 | export const jobsErrorResponse = localhost.query( 121 | 'JobsQuery', 122 | (req, res, ctx) => { 123 | return res( 124 | ctx.errors([ 125 | { 126 | message: 'Mocked error response', 127 | }, 128 | ]) 129 | ) 130 | } 131 | ) 132 | 133 | export const createJobResponse = localhost.mutation( 134 | 'CreateJobMutation', 135 | (req, res, ctx) => { 136 | return res( 137 | ctx.data({ 138 | createJob: { 139 | __typename: 'Job', 140 | name: req.variables.name, 141 | enabled: req.variables.hasOwnProperty('enabled') 142 | ? req.variables.enabled 143 | : true, 144 | body: req.variables.body, 145 | endpoint: req.variables.endpoint, 146 | verb: req.variables.verb, 147 | headers: req.variables.headers, 148 | retryable: req.variables.hasOwnProperty('retryable') 149 | ? req.variables.retryable 150 | : true, 151 | runAt: req.variables.runAt, 152 | runEvery: req.variables.runAt, 153 | createdAt: '2020-01-02T01:00:00Z', 154 | updatedAt: '2020-01-03T02:00:00Z', 155 | lastRunAt: null, 156 | nextRunAt: null, 157 | }, 158 | }) 159 | ) 160 | } 161 | ) 162 | 163 | export const createJobErrorResponse = localhost.mutation( 164 | 'CreateJobMutation', 165 | (req, res, ctx) => { 166 | return res( 167 | ctx.errors([ 168 | { 169 | message: 'Mocked error response', 170 | }, 171 | ]) 172 | ) 173 | } 174 | ) 175 | 176 | export const updateJobResponse = localhost.mutation( 177 | 'UpdateJobMutation', 178 | (req, res, ctx) => { 179 | return res( 180 | ctx.data({ 181 | updateJob: { 182 | __typename: 'Job', 183 | name: req.variables.name || 'test-job-1', 184 | enabled: true, 185 | body: 'foo=bar', 186 | endpoint: 'http://test.host/api/test', 187 | verb: req.variables.verb || 'HEAD', 188 | headers: '{"Content-Type":"text/plain"}', 189 | retryable: true, 190 | runAt: '2020-01-01T00:00:00Z', 191 | runEvery: null, 192 | createdAt: '2020-01-02T01:00:00Z', 193 | updatedAt: '2020-01-03T02:00:00Z', 194 | lastRunAt: null, 195 | nextRunAt: null, 196 | }, 197 | }) 198 | ) 199 | } 200 | ) 201 | 202 | export const updateJobErrorResponse = localhost.mutation( 203 | 'UpdateJobMutation', 204 | (req, res, ctx) => { 205 | return res( 206 | ctx.errors([ 207 | { 208 | message: 'Mocked error response', 209 | }, 210 | ]) 211 | ) 212 | } 213 | ) 214 | 215 | export const deleteJobResponse = localhost.mutation( 216 | 'DeleteJobMutation', 217 | (req, res, ctx) => { 218 | return res( 219 | ctx.data({ 220 | deleteJob: { 221 | __typename: 'Job', 222 | name: req.variables.name || 'test-job-1', 223 | }, 224 | }) 225 | ) 226 | } 227 | ) 228 | 229 | export const deleteJobErrorResponse = localhost.mutation( 230 | 'DeleteJobMutation', 231 | (req, res, ctx) => { 232 | return res( 233 | ctx.errors([ 234 | { 235 | message: 'Mocked error response', 236 | }, 237 | ]) 238 | ) 239 | } 240 | ) 241 | 242 | export const jobResultsResponse = localhost.query( 243 | 'JobResultsQuery', 244 | (req, res, ctx) => { 245 | return res( 246 | ctx.data({ 247 | jobResults: [ 248 | { 249 | __typename: 'JobResult', 250 | status: 200, 251 | body: 'foo=bar', 252 | headers: '{"Content-Type":"text/plain"}', 253 | runAt: '2020-01-01T00:00:00Z', 254 | run: 1, 255 | duration: 1000, 256 | createdAt: '2020-01-02T01:00:00Z', 257 | updatedAt: '2020-01-03T02:00:00Z', 258 | }, 259 | { 260 | __typename: 'JobResult', 261 | status: 500, 262 | body: 'foo=bar', 263 | headers: '{"Content-Type":"text/plain"}', 264 | runAt: '2020-02-01T00:00:00Z', 265 | run: 2, 266 | duration: 500, 267 | createdAt: '2020-02-02T01:00:00Z', 268 | updatedAt: '2020-02-03T02:00:00Z', 269 | }, 270 | ], 271 | }) 272 | ) 273 | } 274 | ) 275 | 276 | export const jobResultsErrorResponse = localhost.query( 277 | 'JobResultsQuery', 278 | (req, res, ctx) => { 279 | return res( 280 | ctx.errors([ 281 | { 282 | message: 'Mocked error response', 283 | }, 284 | ]) 285 | ) 286 | } 287 | ) 288 | -------------------------------------------------------------------------------- /test/queries.test.js: -------------------------------------------------------------------------------- 1 | test('truth', () => { 2 | expect(true).toEqual(true) 3 | }) 4 | -------------------------------------------------------------------------------- /test/repeater.test.js: -------------------------------------------------------------------------------- 1 | import { API_ENDPOINT, Repeater, requiredParams } from '../src/repeater' 2 | import MockDate from 'mockdate' 3 | import { GraphQLError, ParameterError } from '../src/errors' 4 | import { token, endpoint } from './testHelper' 5 | import { 6 | mswServer, 7 | singleJobResponse, 8 | updateJobResponse, 9 | jobErrorResponse, 10 | nullJobResponse, 11 | jobsResponse, 12 | jobsErrorResponse, 13 | createJobResponse, 14 | createJobErrorResponse, 15 | } from './mockedResponses' 16 | 17 | const now = new Date() 18 | 19 | beforeAll(() => { 20 | mswServer.listen() 21 | MockDate.set(now) 22 | }) 23 | 24 | afterEach(() => { 25 | mswServer.resetHandlers() 26 | }) 27 | 28 | afterAll(() => { 29 | MockDate.reset() 30 | mswServer.close() 31 | }) 32 | 33 | // constructor 34 | 35 | test('constructor() without a token throws an error', () => { 36 | expect(() => { 37 | new Repeater() 38 | }).toThrow(ParameterError) 39 | }) 40 | 41 | test('constructor() with an empty string as a token throws an error', () => { 42 | expect(() => { 43 | new Repeater('') 44 | }).toThrow(ParameterError) 45 | }) 46 | 47 | test('constructor() with a token does not throw', () => { 48 | expect(() => new Repeater(token)).not.toThrow() 49 | }) 50 | 51 | // parameters 52 | 53 | test('_token is available as a parameter', () => { 54 | expect(new Repeater(token)._token).toEqual(token) 55 | }) 56 | 57 | test('_options are available as a parameter', () => { 58 | expect(new Repeater(token, { foo: 'bar' })._options.foo).toEqual('bar') 59 | }) 60 | 61 | test('_options sets a default endpoint', () => { 62 | expect(new Repeater(token)._options.endpoint).toEqual(API_ENDPOINT) 63 | }) 64 | 65 | test('options.endpoint can be overridden', () => { 66 | expect(new Repeater(token, { endpoint })._options.endpoint).toEqual(endpoint) 67 | }) 68 | 69 | test('jobs() returns an array of jobs', async () => { 70 | mswServer.resetHandlers(jobsResponse) 71 | const client = new Repeater(token, { endpoint }) 72 | const jobs = await client.jobs() 73 | 74 | expect(jobs.length).toEqual(2) 75 | expect(jobs[0].name).toEqual('test-job-1') 76 | expect(jobs[1].name).toEqual('test-job-2') 77 | }) 78 | 79 | test('jobs() throws a custom error', async () => { 80 | mswServer.resetHandlers(jobsErrorResponse) 81 | const client = new Repeater(token, { endpoint }) 82 | 83 | await expect(client.jobs()).rejects.toThrow(GraphQLError) 84 | }) 85 | 86 | test('job() returns a job with the given name', async () => { 87 | mswServer.resetHandlers(singleJobResponse) 88 | const client = new Repeater(token, { endpoint }) 89 | const job = await client.job('test-job-1') 90 | }) 91 | 92 | test('job() returns null if job not found', async () => { 93 | mswServer.resetHandlers(nullJobResponse) 94 | const client = new Repeater(token, { endpoint }) 95 | const job = await client.job('test-job-99') 96 | 97 | expect(job).toEqual(null) 98 | }) 99 | 100 | test('job() throws a custom error', async () => { 101 | mswServer.resetHandlers(jobErrorResponse) 102 | const client = new Repeater(token, { endpoint }) 103 | 104 | await expect(client.job('test-job-1')).rejects.toThrow(GraphQLError) 105 | }) 106 | 107 | test('enqueue() upcases the verb', async () => { 108 | mswServer.resetHandlers(createJobResponse) 109 | const client = new Repeater(token, { endpoint }) 110 | const random = Math.round(Math.random() * 100) 111 | const job = await client.enqueue({ 112 | name: `test-job-${random}`, 113 | verb: 'get', 114 | endpoint: `http://test.host/api/${random}`, 115 | }) 116 | 117 | expect(job.verb).toEqual('GET') 118 | }) 119 | 120 | test('enqueue() keeps body if present', async () => { 121 | mswServer.resetHandlers(createJobResponse) 122 | const client = new Repeater(token, { endpoint }) 123 | const random = Math.round(Math.random() * 100) 124 | const job = await client.enqueue({ 125 | name: `test-job-${random}`, 126 | verb: 'get', 127 | endpoint: `http://test.host/api/${random}`, 128 | body: `foo=${random}`, 129 | }) 130 | 131 | expect(job.body).toEqual(`foo=${random}`) 132 | }) 133 | 134 | test('enqueue() sets body to stringified json if json params is present', async () => { 135 | mswServer.resetHandlers(createJobResponse) 136 | const client = new Repeater(token, { endpoint }) 137 | const random = Math.round(Math.random() * 100) 138 | const job = await client.enqueue({ 139 | name: `test-job-${random}`, 140 | verb: 'get', 141 | endpoint: `http://test.host/api/${random}`, 142 | json: { foo: random.toString() }, 143 | }) 144 | 145 | expect(job.body).toEqual(`{"foo":"${random}"}`) 146 | }) 147 | 148 | test('enqueue() adds Content-Type header to empty headers if json param present', async () => { 149 | mswServer.resetHandlers(createJobResponse) 150 | const client = new Repeater(token, { endpoint }) 151 | const random = Math.round(Math.random() * 100) 152 | const job = await client.enqueue({ 153 | name: `test-job-${random}`, 154 | verb: 'get', 155 | endpoint: `http://test.host/api/${random}`, 156 | json: { foo: random.toString() }, 157 | }) 158 | 159 | expect(job.headers).toEqual({ 160 | 'Content-Type': 'application/json', 161 | }) 162 | }) 163 | 164 | test('enqueue() adds Content-Type header to existing headers if json param present', async () => { 165 | mswServer.resetHandlers(createJobResponse) 166 | const client = new Repeater(token, { endpoint }) 167 | const random = Math.round(Math.random() * 100) 168 | const job = await client.enqueue({ 169 | name: `test-job-${random}`, 170 | verb: 'get', 171 | endpoint: `http://test.host/api/${random}`, 172 | json: { foo: random.toString() }, 173 | headers: { 'X-Foo': 'bar' }, 174 | }) 175 | 176 | expect(job.headers).toEqual({ 177 | 'X-Foo': 'bar', 178 | 'Content-Type': 'application/json', 179 | }) 180 | }) 181 | 182 | test('enqueue() can override boolean values', async () => { 183 | mswServer.resetHandlers(createJobResponse) 184 | const client = new Repeater(token, { endpoint }) 185 | const random = Math.round(Math.random() * 100) 186 | const job = await client.enqueue({ 187 | name: `test-job-${random}`, 188 | verb: 'get', 189 | endpoint: `http://test.host/api/${random}`, 190 | retryable: false, 191 | }) 192 | 193 | expect(job.retryable).toEqual(false) 194 | }) 195 | 196 | test('enqueue() sets some default values', async () => { 197 | mswServer.resetHandlers(createJobResponse) 198 | const client = new Repeater(token, { endpoint }) 199 | const random = Math.round(Math.random() * 100) 200 | const job = await client.enqueue({ 201 | name: `test-job-${random}`, 202 | verb: 'get', 203 | endpoint: `http://test.host/api/${random}`, 204 | }) 205 | 206 | expect(job.enabled).toEqual(true) 207 | expect(job.retryable).toEqual(true) 208 | expect(job.runAt).toEqual(now) 209 | }) 210 | 211 | test('enqueue() throw custom error', async () => { 212 | mswServer.resetHandlers(createJobErrorResponse) 213 | const client = new Repeater(token, { endpoint }) 214 | 215 | await expect( 216 | client.enqueue({ 217 | name: 'test-job-1', 218 | verb: 'get', 219 | endpoint: 'http://test.host/api', 220 | }) 221 | ).rejects.toThrow(GraphQLError) 222 | }) 223 | 224 | test('enqueueOrUpdate() enqueues a new job if the name does not exist', async () => { 225 | mswServer.resetHandlers(nullJobResponse, createJobResponse) 226 | const client = new Repeater(token, { endpoint }) 227 | const job = await client.enqueueOrUpdate({ 228 | name: 'test-job-1', 229 | verb: 'get', 230 | endpoint: 'http://test.host/api', 231 | }) 232 | 233 | expect(job.name).toEqual('test-job-1') 234 | }) 235 | 236 | test('enqueueOrUpdate() updates an existing job if it exists', async () => { 237 | mswServer.resetHandlers(singleJobResponse, updateJobResponse) 238 | const client = new Repeater(token, { endpoint }) 239 | const job = await client.enqueueOrUpdate({ 240 | name: 'test-job-1', 241 | verb: 'get', 242 | endpoint: 'http://test.host/api', 243 | }) 244 | 245 | expect(job.name).toEqual('test-job-1') 246 | }) 247 | -------------------------------------------------------------------------------- /test/testHelper.js: -------------------------------------------------------------------------------- 1 | export const endpoint = 'http://localhost:3000/graphql' 2 | export const token = '80dd9f1b59e5d33ac187b2983c1effb5' 3 | -------------------------------------------------------------------------------- /test/types/application.test.js: -------------------------------------------------------------------------------- 1 | test('truth', () => { 2 | expect(true).toEqual(true) 3 | }) 4 | -------------------------------------------------------------------------------- /test/types/job.test.js: -------------------------------------------------------------------------------- 1 | import Job from '../../src/types/job' 2 | import { GraphQLError, ReadOnlyError } from '../../src/errors' 3 | import { endpoint, token } from '../testHelper' 4 | import { 5 | mswServer, 6 | updateJobResponse, 7 | updateJobErrorResponse, 8 | deleteJobResponse, 9 | deleteJobErrorResponse, 10 | jobResultsResponse, 11 | jobResultsErrorResponse, 12 | } from '../mockedResponses' 13 | 14 | beforeAll(() => { 15 | mswServer.listen() 16 | }) 17 | 18 | afterEach(() => { 19 | mswServer.resetHandlers() 20 | }) 21 | 22 | afterAll(() => { 23 | mswServer.close() 24 | }) 25 | 26 | test('parses job data on initialization', () => { 27 | const job = new Job( 28 | { 29 | name: 'test-job', 30 | enabled: true, 31 | body: 'foo=bar', 32 | endpoint: 'http://test.host', 33 | verb: 'POST', 34 | headers: '{"Content-Type":"text/plain"}', 35 | retryable: false, 36 | runAt: '2020-01-01T12:00:00Z', 37 | runEvery: 'P1D', 38 | createdAt: '2020-01-02T00:00:00Z', 39 | updatedAt: '2020-01-03T01:00:00Z', 40 | lastRunAt: '2020-01-04T02:00:00Z', 41 | nextRunAt: '2020-01-05T03:00:00Z', 42 | }, 43 | { foo: 'bar', token, endpoint } 44 | ) 45 | 46 | expect(job.name).toEqual('test-job') 47 | expect(job.enabled).toEqual(true) 48 | expect(job.body).toEqual('foo=bar') 49 | expect(job.endpoint).toEqual('http://test.host') 50 | expect(job.verb).toEqual('POST') 51 | expect(job.headers).toEqual({ 'Content-Type': 'text/plain' }) 52 | expect(job.retryable).toEqual(false) 53 | expect(job.runAt).toEqual(new Date('2020-01-01T12:00:00Z')) 54 | expect(job.runEvery).toEqual('P1D') 55 | expect(job.createdAt).toEqual(new Date('2020-01-02T00:00:00Z')) 56 | expect(job.updatedAt).toEqual(new Date('2020-01-03T01:00:00Z')) 57 | expect(job.lastRunAt).toEqual(new Date('2020-01-04T02:00:00Z')) 58 | expect(job.nextRunAt).toEqual(new Date('2020-01-05T03:00:00Z')) 59 | }) 60 | 61 | test('sets blank dates to null', () => { 62 | const job = new Job({}, { foo: 'bar', token, endpoint }) 63 | 64 | expect(job.runAt).toEqual(null) 65 | expect(job.createdAt).toEqual(null) 66 | expect(job.updatedAt).toEqual(null) 67 | expect(job.lastRunAt).toEqual(null) 68 | expect(job.nextRunAt).toEqual(null) 69 | }) 70 | 71 | test('update() updates the properties of the job', async () => { 72 | mswServer.resetHandlers(updateJobResponse) 73 | const job = new Job({ name: 'test-job', verb: 'POST' }, { token, endpoint }) 74 | 75 | expect(job.verb).toEqual('POST') 76 | await job.update({ verb: 'GET' }) 77 | expect(job.verb).toEqual('GET') 78 | }) 79 | 80 | test('update() does not set isDeleted flag', async () => { 81 | mswServer.resetHandlers(updateJobResponse) 82 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 83 | 84 | expect(job.isDeleted).toEqual(false) 85 | await job.update({ verb: 'GET' }) 86 | expect(job.isDeleted).toEqual(false) 87 | }) 88 | 89 | test('update() called on a deleted job throws an error', async () => { 90 | mswServer.resetHandlers(deleteJobResponse) 91 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 92 | await job.delete() 93 | 94 | await expect(job.update()).rejects.toThrow(ReadOnlyError) 95 | }) 96 | 97 | test('update() throws custom error if GraphQL error occurs', async () => { 98 | mswServer.resetHandlers(updateJobErrorResponse) 99 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 100 | 101 | await expect(job.update()).rejects.toThrow(GraphQLError) 102 | }) 103 | 104 | test('delete() sets the isDeleted property to true', async () => { 105 | mswServer.resetHandlers(deleteJobResponse) 106 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 107 | 108 | expect(job.isDeleted).toEqual(false) 109 | await job.delete() 110 | expect(job.isDeleted).toEqual(true) 111 | }) 112 | 113 | test('delete() called on a deleted job throws an error', async () => { 114 | mswServer.resetHandlers(deleteJobResponse) 115 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 116 | await job.delete() 117 | 118 | await expect(job.delete()).rejects.toThrow(ReadOnlyError) 119 | }) 120 | 121 | test('delete() throws custom error if GraphQL error occurs', async () => { 122 | mswServer.resetHandlers(deleteJobErrorResponse) 123 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 124 | 125 | await expect(job.delete()).rejects.toThrow(GraphQLError) 126 | }) 127 | 128 | test('results() returns an array of JobResults', async () => { 129 | mswServer.resetHandlers(jobResultsResponse) 130 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 131 | const results = await job.results() 132 | 133 | expect(results.length).toEqual(2) 134 | expect(results[0].status).toEqual(200) 135 | expect(results[1].status).toEqual(500) 136 | }) 137 | 138 | test('results() passes through token, endpoint and jobName to JobResult instances', async () => { 139 | mswServer.resetHandlers(jobResultsResponse) 140 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 141 | const results = await job.results() 142 | 143 | expect(results[0]._token).toEqual(token) 144 | expect(results[0]._jobName).toEqual('test-job') 145 | expect(results[0]._options.endpoint).toEqual(endpoint) 146 | }) 147 | 148 | test('results() called on a deleted job throws an error', async () => { 149 | mswServer.resetHandlers(deleteJobResponse) 150 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 151 | await job.delete() 152 | 153 | await expect(job.results()).rejects.toThrow(ReadOnlyError) 154 | }) 155 | 156 | test('results() throws custom error if GraphQL error occurs', async () => { 157 | mswServer.resetHandlers(jobResultsErrorResponse) 158 | const job = new Job({ name: 'test-job' }, { token, endpoint }) 159 | 160 | await expect(job.results()).rejects.toThrow(GraphQLError) 161 | }) 162 | -------------------------------------------------------------------------------- /test/types/jobResult.test.js: -------------------------------------------------------------------------------- 1 | import JobResult from '../../src/types/jobResult' 2 | import { GraphQLError } from '../../src/errors' 3 | import { endpoint, token } from '../testHelper' 4 | import { 5 | mswServer, 6 | singleJobResponse, 7 | jobErrorResponse, 8 | } from '../mockedResponses' 9 | 10 | beforeAll(() => { 11 | mswServer.listen() 12 | }) 13 | 14 | afterEach(() => { 15 | mswServer.resetHandlers() 16 | }) 17 | 18 | afterAll(() => { 19 | mswServer.close() 20 | }) 21 | 22 | test('constructor() saves jobName to a property', () => { 23 | const jobResult = new JobResult({}, { jobName: 'test-job' }) 24 | 25 | expect(jobResult._jobName).toEqual('test-job') 26 | }) 27 | 28 | test('parses jobResult data on initialization', () => { 29 | const result = new JobResult( 30 | { 31 | status: 200, 32 | headers: '{"Content-Type":"text/plain"}', 33 | body: 'foo=bar', 34 | runAt: '2020-01-01T12:00:00Z', 35 | run: 1, 36 | duration: 123, 37 | createdAt: '2020-01-02T00:00:00Z', 38 | updatedAt: '2020-01-03T01:00:00Z', 39 | }, 40 | { jobName: 'test-job' } 41 | ) 42 | 43 | expect(result.status).toEqual(200) 44 | expect(result.headers).toEqual({ 'Content-Type': 'text/plain' }) 45 | expect(result.body).toEqual('foo=bar') 46 | expect(result.runAt).toEqual(new Date('2020-01-01T12:00:00Z')) 47 | expect(result.run).toEqual(1) 48 | expect(result.duration).toEqual(123) 49 | expect(result.createdAt).toEqual(new Date('2020-01-02T00:00:00Z')) 50 | expect(result.updatedAt).toEqual(new Date('2020-01-03T01:00:00Z')) 51 | }) 52 | 53 | test('sets blank dates to null', () => { 54 | const result = new JobResult({}, {}) 55 | 56 | expect(result.runAt).toEqual(null) 57 | expect(result.createdAt).toEqual(null) 58 | expect(result.updatedAt).toEqual(null) 59 | }) 60 | 61 | test('job() returns a Job', async () => { 62 | mswServer.resetHandlers(singleJobResponse) 63 | 64 | const result = new JobResult( 65 | { status: 200 }, 66 | { jobName: 'test-job', token, endpoint } 67 | ) 68 | const job = await result.job() 69 | 70 | expect(job.name).toEqual('test-job') 71 | expect(job._token).toEqual(token) 72 | expect(job._options.endpoint).toEqual(endpoint) 73 | }) 74 | 75 | test('job() handles errors', async () => { 76 | mswServer.resetHandlers(jobErrorResponse) 77 | 78 | const result = new JobResult({}, { jobName: 'test-job', token, endpoint }) 79 | 80 | await expect(result.job()).rejects.toThrow(GraphQLError) 81 | }) 82 | -------------------------------------------------------------------------------- /test/types/runner.test.js: -------------------------------------------------------------------------------- 1 | test('truth', () => { 2 | expect(true).toEqual(true) 3 | }) 4 | -------------------------------------------------------------------------------- /test/types/type.test.js: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request' 2 | import Type from '../../src/types/type' 3 | 4 | jest.mock('graphql-request') 5 | 6 | beforeEach(() => { 7 | GraphQLClient.mockClear() 8 | }) 9 | 10 | test('constructor() saves the given token', () => { 11 | const type = new Type({}, { token: 'abc' }) 12 | 13 | expect(type._token).toEqual('abc') 14 | expect(type._options).toEqual({}) 15 | }) 16 | 17 | test('constructor() saves any additional options', () => { 18 | const type = new Type({}, { token: 'abc', foo: 'bar' }) 19 | 20 | expect(type._options).toEqual({ foo: 'bar' }) 21 | }) 22 | 23 | test('constructor() saves the passed data', () => { 24 | const type = new Type({ foo: 'bar' }, { token: 'abc' }) 25 | 26 | expect(type.data).toEqual({ foo: 'bar' }) 27 | }) 28 | 29 | test('constructor() initializes the GraphQL client', () => { 30 | new Type({}, { token: 'abc', endpoint: 'http://test.host' }) 31 | 32 | expect(GraphQLClient).toHaveBeenCalledWith('http://test.host', { 33 | headers: { authorization: `Bearer abc` }, 34 | }) 35 | }) 36 | 37 | test('constructor() defaults the isDeleted property to false', () => { 38 | const type = new Type({}, { token: 'abc', endpoint: 'http://test.host' }) 39 | 40 | expect(type.isDeleted).toEqual(false) 41 | }) 42 | -------------------------------------------------------------------------------- /test/utility.test.js: -------------------------------------------------------------------------------- 1 | import { merge, normalizeParams } from '../src/utility' 2 | 3 | test('merge() merges two objects', () => { 4 | const output = merge({ foo: 'bar' }, { baz: 'qux' }) 5 | 6 | expect(output.foo).toEqual('bar') 7 | expect(output.baz).toEqual('qux') 8 | }) 9 | 10 | test('merge() ignores null values when merging', () => { 11 | const output = merge({ foo: 'bar' }, { baz: null }) 12 | 13 | expect(output.foo).toEqual('bar') 14 | expect('baz' in output).toEqual(false) 15 | }) 16 | 17 | test('merge() ignores undefined values when merging', () => { 18 | const output = merge({ foo: 'bar' }, { baz: undefined }) 19 | 20 | expect(output.foo).toEqual('bar') 21 | expect('baz' in output).toEqual(false) 22 | }) 23 | 24 | test('merge() prioritizes values in second object over values in first', () => { 25 | const output = merge({ foo: 'bar' }, { foo: 'baz' }) 26 | 27 | expect(output.foo).toEqual('baz') 28 | }) 29 | 30 | test('normalizeParams() upcases verb', async () => { 31 | const normalized = normalizeParams({ verb: 'post' }) 32 | 33 | expect(normalized.verb).toEqual('POST') 34 | }) 35 | 36 | test('normalizeParams() transforms json into body, appends header', async () => { 37 | const normalized = normalizeParams({ json: { bar: 'baz' } }) 38 | 39 | expect(normalized.body).toEqual(`{"bar":"baz"}`) 40 | expect(normalized.json).toEqual(undefined) 41 | expect(normalized.headers).toEqual(`{"Content-Type":"application/json"}`) 42 | }) 43 | 44 | test('normalizeParams() passes through regular body', async () => { 45 | const normalized = normalizeParams({ body: `foo=bar` }) 46 | 47 | expect(normalized.body).toEqual(`foo=bar`) 48 | expect(normalized.json).toEqual(undefined) 49 | expect(normalized.headers).toEqual(undefined) 50 | }) 51 | 52 | test('normalizeParams() prioritizes body over json', async () => { 53 | const normalized = normalizeParams({ 54 | body: 'foo=bar', 55 | json: { bar: 'baz' }, 56 | }) 57 | expect(normalized.body).toEqual(`foo=bar`) 58 | expect(normalized.json).toEqual(undefined) 59 | expect(normalized.headers).toEqual(undefined) 60 | }) 61 | --------------------------------------------------------------------------------