diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 5ca948e..0000000 --- a/.drone.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- -kind: pipeline -type: docker - -steps: -- name: build - image: node:12 - commands: - - npm install -- name: unit-test - image: node:12 - commands: - - npm test -- name: promote_test - image: node:12 - commands: - - echo "Testing Promote" - when: - event: - - promote diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6cebf47..0000000 --- a/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: node_js -node_js: -- '8' -- '10' -- '12' -before_install: npm install -g grunt-cli diff --git a/README.md b/README.md index 335a11a..cfcc01a 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ -# strava-v3: Simple Node wrapper for Strava's v3 API +# strava-v3 + +A simple Node.js wrapper for Strava's v3 API [![NPM Version][npm-image]][npm-url] [![NPM Downloads][downloads-image]][downloads-url] -[![Build Status][travis-image]][travis-url] +[![Test Suite][github-actions-image]][github-actions-url] [npm-image]: https://img.shields.io/npm/v/strava-v3.svg?style=flat [npm-url]: https://npmjs.org/package/strava-v3 [downloads-image]: https://img.shields.io/npm/dm/strava-v3.svg?style=flat [downloads-url]: https://npmjs.org/package/strava-v3 -[travis-image]: https://travis-ci.org/UnbounDev/node-strava-v3.svg?branch=master&style=flat -[travis-url]: https://travis-ci.org/UnbounDev/node-strava-v3 - -### Status +[github-actions-image]: https://github.com/node-strava/node-strava-v3/actions/workflows/on-pull-request.yml/badge.svg +[github-actions-url]: https://github.com/node-strava/node-strava-v3/actions/workflows/on-pull-request.yml Supports many but not all Strava API endpoints: @@ -22,7 +22,6 @@ Supports many but not all Strava API endpoints: * `activities` * `clubs` * `gear` -* `running_races` * `routes` * `segments` * `segment_efforts` @@ -36,12 +35,16 @@ npm install strava-v3 ``` ## Import syntax + Importing only the library: -``` + +```js import strava from 'strava-v3'; ``` + Importing both the library as well as interfaces: -``` + +```js import { default as strava, Strava } from 'strava-v3'; ``` @@ -97,15 +100,15 @@ strava.config({ "redirect_uri" : "Your apps Authorization Redirection URI (Required for oauth)", }); ``` + ##### Environment variables You may alternatively supply the values via environment variables named following the convention `STRAVA_`, so -- `STRAVA_ACCESS_TOKEN` = `access_token` -- `STRAVA_CLIENT_ID` = `client_id` -- `STRAVA_CLIENT_SECRET` = `client_secret` -- `STRAVA_REDIRECT_URI` = `redirect_uri` - +* `STRAVA_ACCESS_TOKEN` = `access_token` +* `STRAVA_CLIENT_ID` = `client_id` +* `STRAVA_CLIENT_SECRET` = `client_secret` +* `STRAVA_REDIRECT_URI` = `redirect_uri` #### Config File (Deprecated) @@ -161,7 +164,7 @@ strava = new stravaApi.client(access_token); const payload = await strava.athlete.get({}) ``` -Less conveniently, you can also explictly pass an `access_token` to API calls: +Less conveniently, you can also explicitly pass an `access_token` to API calls: Example usage: @@ -178,13 +181,14 @@ Example usage: ```js const strava = require('strava-v3'); -const payload = await strava.athlete.listFollowers({ +const payload = await strava.athlete.listActivities({ page: 1, per_page: 2 }); ``` ### Uploading files + To upload a file you'll have to pass in the `data_type` as specified in Strava's API reference as well as a string `file` designating the `/`. If you want to get updates on the status of your upload pass in `statusCallback` along with the rest of your `args` - the wrapper will check on the upload once a second until complete. Example usage: @@ -202,6 +206,7 @@ const payload = await strava.uploads.post({ ``` ### Rate limits + According to Strava's API each response contains information about rate limits. For more details see: [Rate Limits](https://developers.strava.com/docs/rate-limits/) @@ -250,6 +255,7 @@ strava.athlete.get({'access_token':'abcde'},function(err,payload,limits) { */ }); ``` + ### Supported API Endpoints To used the Promise-based API, do not provide a callback. A promise will be returned. @@ -301,41 +307,38 @@ See Strava API docs for returned data structures. These methods Authenticate with a Client ID and Client Secret. Since they don't use OAuth, they are not available on the `client` object. - * `strava.pushSubscriptions.list({},done)` - * `strava.pushSubscriptions.create({callback_url:...},done)` - * We set 'object\_type to "activity" and "aspect\_type" to "create" for you. - * `strava.pushSubscriptions.delete({id:...},done)` - -#### Running Races - - * `strava.runningRaces.get(args,done)` - * `strava.runningRaces.listRaces(args,done)` +* `strava.pushSubscriptions.list({},done)` +* `strava.pushSubscriptions.create({callback_url:...},done)` +* We set 'object\_type to "activity" and "aspect\_type" to "create" for you. +* `strava.pushSubscriptions.delete({id:...},done)` #### Routes - * `strava.routes.getFile({ id: routeId, file_type: 'gpx' },done)` *file_type may also be 'tcx'* - * `strava.routes.get(args,done)` +* `strava.routes.getFile({ id: routeId, file_type: 'gpx' },done)` *file_type may also be 'tcx'* +* `strava.routes.get(args,done)` #### Segments - * `strava.segments.get(args,done)` - * `strava.segments.listStarred(args,done)` - * `strava.segments.listEfforts(args,done)` - * `strava.segments.explore(args,done)` *Expects arg `bounds` as a comma separated string, for two points describing a rectangular boundary for the search: `"southwest corner latitutde, southwest corner longitude, northeast corner latitude, northeast corner longitude"`*. +* `strava.segments.get(args,done)` +* `strava.segments.listStarred(args,done)` +* `strava.segments.listEfforts(args,done)` +* `strava.segments.explore(args,done)` *Expects arg `bounds` as a comma separated string, for two points describing a rectangular boundary for the search: `"southwest corner latitude, southwest corner longitude, northeast corner latitude, northeast corner longitude"`*. +* `strava.segments.starSegment(args,done)` #### Segment Efforts - * `strava.segmentEfforts.get(args,done)` +* `strava.segmentEfforts.get(args,done)` #### Streams - * `strava.streams.activity(args,done)` - * `strava.streams.effort(args,done)` - * `strava.streams.segment(args,done)` +* `strava.streams.activity(args,done)` +* `strava.streams.effort(args,done)` +* `strava.streams.segment(args,done)` +* `strava.streams.route(args,done)` #### Uploads - * `strava.uploads.post(args,done)` +* `strava.uploads.post(args,done)` ## Error Handling @@ -369,22 +372,21 @@ Example error checking: The `StatusCodeError` object includes extra properties to help with debugging: - - `name` is always `StatusCodeError` - - `statusCode` contains the HTTP status code - - `message` contains the response's status message and additional error details - - `data` contains the body of the response, which can be useful for debugging - - `options` contains the options used in the request - - `response` contains the response object +* `name` is always `StatusCodeError` +* `statusCode` contains the HTTP status code +* `message` contains the response's status message and additional error details +* `data` contains the body of the response, which can be useful for debugging +* `options` contains the options used in the request +* `response` contains the response object The `RequestError` object is used for errors that occur due to technical issues, such as no response being received or request setup issues, and includes the following properties: -- `name` is always `RequestError` -- `message` contains the error message -- `options` contains the options used in the request +* `name` is always `RequestError` +* `message` contains the error message +* `options` contains the options used in the request This update maintains feature parity with the previous implementation of `request-promise` while using the Axios HTTP client under the hood. - ## Development This package includes a full test suite runnable via `yarn test`. @@ -392,48 +394,30 @@ It will both lint and run tests on API endpoints. ### Running the tests -Many unit tests now use nock to mock the Strava API and can run without any real credentials. -However, some integration-style tests still expect a real token and account data. +All tests use `nock` to mock the Strava API and can run without any real credentials or network access. -If you want to run the full test suite (including integration tests), you'll need to supply `data/strava_config` with an `access_token` that -has both private read and write permissions. Look in `./scripts` for a tool -to help generate this token. +Simply run: -* Make sure you've filled out all the fields in `data/strava_config`. -* Use `strava.oauth.getRequestAccessURL({scope:"view_private,write"})` to generate the request url and query it via your browser. -* Strava will prompt you (the user) to allow access, say yes and you'll be sent to your Authorization Redirection URI - the parameter `code` will be included in the redirection url. -* Exchange the `code` for a new `access_token`: - -```js -// access_token is at payload.access_token -const payload = await strava.oauth.getToken(authorizationCode) +```bash +yarn test ``` -Finally, the test suite has some expectations about the Strava account that it -connects for the tests to pass. The following should be true about the Strava -data in the account: - * Must have at least one activity posted on Strava - * Must have joined at least one club - * Must have added at least one piece of gear (bike or shoes) - * Must have created at least one route - * Most recent activity with an achievement should also contain a segment +The test suite will: -(Parts of the test suite already use `nock` to mock the API. Contributions to convert remaining integration tests to mocks are welcome.) - -You're done! Paste the new `access_token` to `data/strava_config` and run the full tests: - -`yarn test`. +* Run ESLint on all JavaScript files +* Execute all unit tests using mocked API responses ### How the tests work -- Tests use Mocha and Should.js. -- HTTP interaction is performed with Axios; tests that mock HTTP use `nock`. +* Tests use Mocha and Node.js's built-in `assert` module +* HTTP interaction is performed with Axios; all tests mock HTTP requests using `nock` -Using the provided `access_token` tests will access each endpoint individually: +The test suite validates: -* (For all `GET` endpoints) checks to ensure the correct type has been returned from the Strava. -* (For `PUT` in `athlete.update`) changes some athlete properties, then changes them back. -* (For `POST/PUT/DELETE` in `activities.create/update/delete`) first creates an activity, runs some operations on it, then deletes it. +* All `GET` endpoints return the correct data structure +* All `POST`/`PUT`/`DELETE` endpoints handle requests and responses correctly +* Error handling works as expected +* Rate limiting functionality is properly tested ## Resources @@ -442,7 +426,6 @@ Using the provided `access_token` tests will access each endpoint individually: ## Author and Maintainer -Authored by Austin Brown (http://austinjamesbrown.com/). - -Currently Maintained by Mark Stosberg +Authored by Austin Brown (). +Currently Maintained by Mark Stosberg and Wesley Schlenker diff --git a/index.d.ts b/index.d.ts index d61ee78..535d9c6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -67,8 +67,8 @@ export interface UploadResponse { export interface SegmentsRoutes { get(args: any, done?: Callback): Promise; listStarred(args: any, done?: Callback): Promise; + starSegment(args: any, done?: Callback): Promise; listEfforts(args: any, done?: Callback): Promise; - listLeaderboard(args: any, done?: Callback): Promise; explore(args: any, done?: Callback): Promise; } @@ -80,6 +80,7 @@ export interface StreamsRoutes { activity(args: any, done?: Callback): Promise; effort(args: any, done?: Callback): Promise; segment(args: any, done?: Callback): Promise; + route(args: any, done?: Callback): Promise; } export interface RoutesRoutes { @@ -107,11 +108,7 @@ export interface ClubsRoutes { args: ClubsRoutesListArgs, done?: Callback ): Promise; - listAnnouncements(args: ClubsRoutesListArgs, done?: Callback): Promise; - listEvents(args: ClubsRoutesListArgs, done?: Callback): Promise; listAdmins(args: ClubsRoutesListArgs, done?: Callback): Promise; - joinClub(args: ClubsRoutesListArgs, done?: Callback): Promise; - leaveClub(args: ClubsRoutesListArgs, done?: Callback): Promise; } export interface ClubsRoutesArgs extends BaseArgs { @@ -140,7 +137,6 @@ export interface ClubActivity { } export interface AthletesRoutes { - get(args: AthleteRouteArgs, done?: Callback): Promise; stats(args: any, done?: Callback): Promise; } @@ -286,13 +282,10 @@ export interface ActivitiesRoutes { get(args: any, done?: Callback): Promise; create(args: any, done?: Callback): Promise; update(args: any, done?: Callback): Promise; - listFriends(args: any, done?: Callback): Promise; listZones(args: any, done?: Callback): Promise; listLaps(args: any, done?: Callback): Promise; listComments(args: any, done?: Callback): Promise; listKudos(args: any, done?: Callback): Promise; - listPhotos(args: any, done?: Callback): Promise; - listRelated(args: any, done?: Callback): Promise; } export interface AthleteRoutes { diff --git a/lib/athlete.js b/lib/athlete.js index ebf86e3..7f24aa0 100644 --- a/lib/athlete.js +++ b/lib/athlete.js @@ -25,6 +25,9 @@ athlete.prototype.get = async function (args) { athlete.prototype.listActivities = async function (args) { return await this._listHelper('activities', args) } +athlete.prototype.listRoutes = async function (args) { + return await this._listHelper('routes', args) +} athlete.prototype.listClubs = async function (args) { return await this._listHelper('clubs', args) } diff --git a/package.json b/package.json index bb88b72..730b745 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "strava-v3", - "version": "3.2.0", + "version": "3.3.0", "description": "Simple wrapper for Strava v3 API", "main": "index.js", "types": "index.d.ts", diff --git a/test/_helper.js b/test/_helper.js index c3ad5f7..0a7df82 100644 --- a/test/_helper.js +++ b/test/_helper.js @@ -16,87 +16,4 @@ testsHelper.cleanupAuth = function () { authenticator.purge() } -testsHelper.getSampleAthlete = async function () { - return await strava.athlete.get({}) -} - -testsHelper.getSampleActivity = function (done) { - strava.athlete.listActivities({ include_all_efforts: true }, function (err, payload) { - if (err) { return done(err) } - - if (!payload.length) { return done(new Error('Must have at least one activity posted to Strava to test with.')) } - - // If we find an activity with an achievement, there's a better chance - // that it contains a segment. - // This is necessary for getSampleSegment, which uses this function. - function hasAchievement (activity) { return activity.achievement_count > 1 } - - var withSegment = payload.filter(hasAchievement)[0] - - if (!withSegment) { return done(new Error('Must have at least one activity posted to Strava with a segment effort to test with.')) } - - return strava.activities.get({ id: withSegment.id, include_all_efforts: true }, done) - }) -} - -testsHelper.getSampleClub = function (done) { - strava.athlete.listClubs({}, function (err, payload) { - if (err) { return done(err) } - - if (!payload.length) { return done(new Error('Must have joined at least one club on Strava to test with.')) } - - done(err, payload[0]) - }) -} - -testsHelper.getSampleRoute = function (done) { - strava.athlete.listRoutes({}, function (err, payload) { - if (err) { return done(err) } - - if (!payload.length) { return done(new Error('Must have created at least one route on Strava to test with.')) } - - done(err, payload[0]) - }) -} - -testsHelper.getSampleGear = async function () { - const payload = await this.getSampleAthlete() - - var gear - - if (payload.bikes && payload.bikes.length) { - gear = payload.bikes[0] - } else if (payload.shoes) { - gear = payload.shoes[0] - } else { - throw new Error('Must post at least one bike or shoes to Strava to test with') - } - - return gear -} - -testsHelper.getSampleSegmentEffort = function (done) { - this.getSampleActivity(function (err, payload) { - if (err) { return done(err) } - - if (!payload.segment_efforts.length) { return done(new Error('Must have at least one segment effort posted to Strava to test with.')) } - - done(err, payload.segment_efforts[0]) - }) -} - -testsHelper.getSampleSegment = function (done) { - this.getSampleSegmentEffort(function (err, payload) { - if (err) { return done(err) } - - done(err, payload.segment) - }) -} - -testsHelper.getSampleRunningRace = function (done) { - strava.runningRaces.listRaces({ 'year': 2015 }, function (err, payload) { - done(err, payload[0]) - }) -} - module.exports = testsHelper diff --git a/test/activities.js b/test/activities.js index 98aff4e..e3b751d 100644 --- a/test/activities.js +++ b/test/activities.js @@ -124,7 +124,7 @@ describe('activities_test', function () { }) }) - describe('#updateSportType()', function () { + describe('#update()', function () { it('should update the sport type of an activity', async function () { const sportType = 'MountainBikeRide' const args = { @@ -149,18 +149,102 @@ describe('activities_test', function () { }) }) - // TODO can't test b/c this requires premium account describe('#listZones()', function () { - xit('should list heart rate and power zones relating to activity', function (done) { - strava.activities.listZones({ id: testActivity.id }, function (err, payload) { - if (!err) { - assert.ok(Array.isArray(payload)) - } else { - console.log(err) - } - - done() - }) + it('should list heart rate and power zones relating to activity', async function () { + // Mock the list zones API call + nock('https://www.strava.com') + .get('/api/v3/activities/' + testActivity.id + '/zones') + .reply(200, [ + { + score: 82, + distribution_buckets: [ + { + max: 0, + min: 0, + time: 1498 + }, + { + max: 50, + min: 0, + time: 62 + }, + { + max: 100, + min: 50, + time: 169 + } + ], + type: 'power', + sensor_based: true, + points: 250, + custom_zones: false, + max: 450 + }, + { + score: 75, + distribution_buckets: [ + { + max: 0, + min: 0, + time: 200 + }, + { + max: 100, + min: 0, + time: 150 + }, + { + max: 120, + min: 100, + time: 300 + } + ], + type: 'heartrate', + sensor_based: false, + points: 180, + custom_zones: true, + max: 200 + } + ]) + + const payload = await strava.activities.listZones({ id: testActivity.id }) + assert.ok(Array.isArray(payload)) + assert.strictEqual(payload.length, 2) + + // Verify power zone + const powerZone = payload.find(zone => zone.type === 'power') + assert.ok(powerZone) + assert.strictEqual(typeof powerZone.score, 'number') + assert.strictEqual(powerZone.score, 82) + assert.ok(Array.isArray(powerZone.distribution_buckets)) + assert.strictEqual(powerZone.distribution_buckets.length, 3) + assert.strictEqual(powerZone.type, 'power') + assert.strictEqual(powerZone.sensor_based, true) + assert.strictEqual(typeof powerZone.points, 'number') + assert.strictEqual(powerZone.points, 250) + assert.strictEqual(powerZone.custom_zones, false) + assert.strictEqual(typeof powerZone.max, 'number') + assert.strictEqual(powerZone.max, 450) + + // Verify distribution bucket structure + const bucket = powerZone.distribution_buckets[0] + assert.strictEqual(typeof bucket.min, 'number') + assert.strictEqual(typeof bucket.max, 'number') + assert.strictEqual(typeof bucket.time, 'number') + + // Verify heartrate zone + const heartrateZone = payload.find(zone => zone.type === 'heartrate') + assert.ok(heartrateZone) + assert.strictEqual(typeof heartrateZone.score, 'number') + assert.strictEqual(heartrateZone.score, 75) + assert.ok(Array.isArray(heartrateZone.distribution_buckets)) + assert.strictEqual(heartrateZone.type, 'heartrate') + assert.strictEqual(heartrateZone.sensor_based, false) + assert.strictEqual(typeof heartrateZone.points, 'number') + assert.strictEqual(heartrateZone.points, 180) + assert.strictEqual(heartrateZone.custom_zones, true) + assert.strictEqual(typeof heartrateZone.max, 'number') + assert.strictEqual(heartrateZone.max, 200) }) }) @@ -208,19 +292,4 @@ describe('activities_test', function () { assert.ok(Array.isArray(payload)) }) }) - - // TODO check w/ strava dudes, this is returning undefined instead of an empty array (no photos) - describe('#listPhotos()', function () { - xit('should list photos relating to activity', function (done) { - strava.activities.listPhotos({ id: testActivity.id }, function (err, payload) { - if (!err) { - assert.ok(Array.isArray(payload)) - } else { - console.log(err) - } - - done() - }) - }) - }) })