From 16feaf9000da25ccfb5bcddafbf2b4292f83c45c Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Mon, 16 May 2016 19:35:50 -0400 Subject: [PATCH] Add wp/v2/comments endpoint handler and corresponding tests Comments are not currently supported as a top-level endpoint. This adds a basic `/comments` endpoint handler, which can - Retrieve a paged collection of comments - Retrieve a specific comment by ID - Retrieve comments by their parent post or page via the `.forPost()` query parameter method Unit tests and integration tests have been added to validate the enumerated behaviors of the endpoint handler work as expected. Further [comment query parameters](http://v2.wp-api.org/reference/comments/) will follow in subsequent PRs, possibly following #145. --- lib/comments.js | 182 ++++++++++++++++++++++++ tests/integration/comments.js | 254 ++++++++++++++++++++++++++++++++++ tests/unit/lib/comments.js | 149 ++++++++++++++++++++ wp.js | 14 ++ 4 files changed, 599 insertions(+) create mode 100644 lib/comments.js create mode 100644 tests/integration/comments.js create mode 100644 tests/unit/lib/comments.js diff --git a/lib/comments.js b/lib/comments.js new file mode 100644 index 00000000..0b3c8630 --- /dev/null +++ b/lib/comments.js @@ -0,0 +1,182 @@ +'use strict'; +/** + * @module WP + * @submodule CommentsRequest + * @beta + */ +var CollectionRequest = require( './shared/collection-request' ); +var inherit = require( 'util' ).inherits; + +/** + * CommentsRequest extends CollectionRequest to handle the /comments API endpoint + * + * @class CommentsRequest + * @constructor + * @extends CollectionRequest + * @param {Object} options A hash of options for the CommentsRequest instance + * @param {String} options.endpoint The endpoint URI for the invoking WP instance + * @param {String} [options.username] A username for authenticating API requests + * @param {String} [options.password] A password for authenticating API requests + */ +function CommentsRequest( options ) { + /** + * Configuration options for the request such as the endpoint for the invoking WP instance + * @property _options + * @type Object + * @private + * @default {} + */ + this._options = options || {}; + + /** + * A hash of non-filter query parameters + * + * @property _params + * @type Object + * @private + * @default {} + */ + this._params = {}; + + /** + * A hash of values to assemble into the API request path + * + * @property _path + * @type Object + * @private + * @default {} + */ + this._path = {}; + + /** + * The URL template that will be used to assemble endpoint paths + * + * @property _template + * @type String + * @protected + * @default 'comments(/:id)' + */ + this._template = 'comments(/:id)'; + + /** + * @property _supportedMethods + * @type Array + * @private + * @default [ 'head', 'get', 'post' ] + */ + this._supportedMethods = [ 'head', 'get', 'post' ]; + + // Default all .comments() requests to assume a query against the WP API v2 endpoints + this.namespace( 'wp/v2' ); +} + +// CommentsRequest extends CollectionRequest +inherit( CommentsRequest, CollectionRequest ); + +/** + * A hash table of path keys and regex validators for those path elements + * + * @property _pathValidators + * @type Object + * @private + */ +CommentsRequest.prototype._pathValidators = { + + /** + * ID must be an integer + * + * @property _pathValidators.id + * @type {RegExp} + */ + id: /^\d+$/ +}; + +/** + * Specify a post ID to query + * + * @method id + * @chainable + * @param {Number} id The ID of a post to retrieve + * @return {CommentsRequest} The CommentsRequest instance (for chaining) + */ +CommentsRequest.prototype.id = function( id ) { + this._path.id = parseInt( id, 10 ); + this._supportedMethods = [ 'head', 'get', 'put', 'post', 'delete' ]; + + return this; +}; + +/** + * Specify the name of the taxonomy collection to query + * + * The collections will not be a strict match to defined comments: *e.g.*, to + * get the list of terms for the taxonomy "category," you must specify the + * collection name "categories" (similarly, specify "tags" to get a list of terms + * for the "post_tag" taxonomy). + * + * To get the dictionary of all available comments, specify the collection + * "taxonomy" (slight misnomer: this case will return an object, not the array + * that would usually be expected with a "collection" request). + * + * @method collection + * @chainable + * @param {String} taxonomyCollection The name of the taxonomy collection to query + * @return {CommentsRequest} The CommentsRequest instance (for chaining) + */ +CommentsRequest.prototype.collection = function( taxonomyCollection ) { + this._path.collection = taxonomyCollection; + + return this; +}; + +/** + * Specify a taxonomy term to request + * + * @method term + * @chainable + * @param {String} term The ID or slug of the term to request + * @return {CommentsRequest} The CommentsRequest instance (for chaining) + */ +CommentsRequest.prototype.term = function( term ) { + this._path.term = term; + + return this; +}; + +/** + * Search for hierarchical taxonomy terms that are children of the parent term + * indicated by the provided term ID + * + * @example + * + * wp.categories().parent( 42 ).then(function( categories ) { + * console.log( 'all of these categories are sub-items of cat ID#42:' ); + * console.log( categories ); + * }); + * + * @method parent + * @chainable + * @param {Number} parentId The ID of a (hierarchical) taxonomy term + * @return {CommentsRequest} The CommentsRequest instance (for chaining) + */ +CommentsRequest.prototype.parent = function( parentId ) { + this.param( 'parent', parentId, true ); + + return this; +}; + +/** + * Specify the post for which to retrieve terms + * + * @method forPost + * @chainable + * @param {String|Number} post The ID of the post for which to retrieve terms + * @return {CommentsRequest} The CommentsRequest instance (for chaining) + */ +CommentsRequest.prototype.forPost = function( postId ) { + this.param( 'post', postId ); + + return this; +}; + +module.exports = CommentsRequest; diff --git a/tests/integration/comments.js b/tests/integration/comments.js new file mode 100644 index 00000000..5673c990 --- /dev/null +++ b/tests/integration/comments.js @@ -0,0 +1,254 @@ +'use strict'; +/*jshint -W106 */// Disable underscore_case warnings in this file b/c WP uses them +var chai = require( 'chai' ); +// Variable to use as our 'success token' in promise assertions +var SUCCESS = 'success'; +// Chai-as-promised and the `expect( prom ).to.eventually.equal( SUCCESS ) is +// used to ensure that the assertions running within the promise chains are +// actually run. +chai.use( require( 'chai-as-promised' ) ); +var expect = chai.expect; + +var WP = require( '../../' ); +var WPRequest = require( '../../lib/shared/wp-request.js' ); + +// Define some arrays to use ensuring the returned data is what we expect +// it to be (e.g. an array of the titles from posts on the first page) +var expectedResults = { + postsAndAuthors: { + page1: [ + '155tellyworthtest2', + '155Anon', + '155John Doe', + '1149John Doe', + '1148John Doe', + '1148Anonymous User', + '1148Jane Doe', + '1148John Doe', + '1148John Doe', + '1148John Doe' + ], + page2: [ + '1148Jane Bloggs', + '1148Fred Bloggs', + '1148Fred Bloggs', + '1148Joe Bloggs', + '1148Jane Bloggs', + '1148Joe Bloggs', + '1148Jane Bloggs', + '1148Joe Bloggs', + '1148John Doe', + '1148Jane Doe' + ], + page3: [ + '1148John Doe', + '1148John Doe', + '1148Jane Doe', + '1168Jane Doe', + '1170John Doe' + ] + } +}; + +// Inspecting the posts and authors of the returned comments arrays is an easy +// way to validate that the right page of results was returned +function getPostsAndAuthors( posts ) { + return posts.map(function( post ) { + return post.post + post.author_name; + }); +} + +describe( 'integration: comments()', function() { + var wp; + + beforeEach(function() { + wp = new WP({ + endpoint: 'http://wpapi.loc/wp-json' + }); + }); + + it( 'can be used to retrieve a list of recent comments', function() { + var prom = wp.comments().get().then(function( comments ) { + expect( comments ).to.be.an( 'array' ); + expect( comments.length ).to.equal( 10 ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'fetches the 10 most recent comments by default', function() { + var prom = wp.comments().get().then(function( comments ) { + expect( getPostsAndAuthors( comments ) ).to.deep.equal( expectedResults.postsAndAuthors.page1 ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + describe( 'paging properties', function() { + + it( 'are exposed as _paging on the response array', function() { + var prom = wp.comments().get().then(function( posts ) { + expect( posts ).to.have.property( '_paging' ); + expect( posts._paging ).to.be.an( 'object' ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'include the total number of posts', function() { + var prom = wp.comments().get().then(function( posts ) { + expect( posts._paging ).to.have.property( 'total' ); + expect( posts._paging.total ).to.equal( '25' ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'include the total number of pages available', function() { + var prom = wp.comments().get().then(function( posts ) { + expect( posts._paging ).to.have.property( 'totalPages' ); + expect( posts._paging.totalPages ).to.equal( '3' ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'provides a bound WPRequest for the next page as .next', function() { + var prom = wp.comments().get().then(function( posts ) { + expect( posts._paging ).to.have.property( 'next' ); + expect( posts._paging.next ).to.be.an( 'object' ); + expect( posts._paging.next ).to.be.an.instanceOf( WPRequest ); + expect( posts._paging.next._options.endpoint ).to + .equal( 'http://wpapi.loc/wp-json/wp/v2/comments?page=2' ); + // Get last page & ensure 'next' no longer appears + return wp.comments().page( posts._paging.totalPages ).get().then(function( posts ) { + expect( posts._paging ).not.to.have.property( 'next' ); + expect( getPostsAndAuthors( posts ) ).to.deep.equal( expectedResults.postsAndAuthors.page3 ); + return SUCCESS; + }); + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'allows access to the next page of results via .next', function() { + var prom = wp.comments().get().then(function( posts ) { + return posts._paging.next.get().then(function( posts ) { + expect( posts ).to.be.an( 'array' ); + expect( posts.length ).to.equal( 10 ); + expect( getPostsAndAuthors( posts ) ).to.deep.equal( expectedResults.postsAndAuthors.page2 ); + return SUCCESS; + }); + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'provides a bound WPRequest for the previous page as .prev', function() { + var prom = wp.comments().get().then(function( posts ) { + expect( posts._paging ).not.to.have.property( 'prev' ); + return posts._paging.next.get().then(function( posts ) { + expect( posts._paging ).to.have.property( 'prev' ); + expect( posts._paging.prev ).to.be.an( 'object' ); + expect( posts._paging.prev ).to.be.an.instanceOf( WPRequest ); + expect( posts._paging.prev._options.endpoint ).to + .equal( 'http://wpapi.loc/wp-json/wp/v2/comments?page=1' ); + return SUCCESS; + }); + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'allows access to the previous page of results via .prev', function() { + var prom = wp.comments().page( 2 ).get().then(function( posts ) { + expect( getPostsAndAuthors( posts ) ).to.deep.equal( expectedResults.postsAndAuthors.page2 ); + return posts._paging.prev.get().then(function( posts ) { + expect( posts ).to.be.an( 'array' ); + expect( posts.length ).to.equal( 10 ); + expect( getPostsAndAuthors( posts ) ).to.deep.equal( expectedResults.postsAndAuthors.page1 ); + return SUCCESS; + }); + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + }); + + describe( 'querying by ID', function() { + var commentCollection; + var commentId; + var commentProm; + + beforeEach(function() { + commentCollection = []; + commentProm = wp.comments().get().then(function( comments ) { + commentCollection = comments; + commentId = commentCollection[4].id; + return wp.comments().id( commentId ).get(); + }); + }); + + it( 'returns an object, not an array', function() { + var prom = commentProm.then(function( comment ) { + expect( Array.isArray( comment ) ).to.equal( false ); + expect( comment ).to.be.an( 'object' ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'returns the correct comment', function() { + var prom = commentProm.then(function( comment ) { + expect( comment.id ).to.equal( commentId ); + [ 'author_name', 'post', 'parent', 'date', 'status' ].forEach(function( prop ) { + expect( comment[ prop ] ).to.equal( commentCollection[4][ prop ] ); + }); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + }); + + describe( 'forPost() query', function() { + var pageComments; + var commentProm; + + beforeEach(function() { + var pageId = 155; + commentProm = wp.pages().id( pageId ).embed().get().then(function( page ) { + // Do a flatten reduction because .replies will be an array of arrays + pageComments = page._embedded.replies.reduce(function( flatArr, arr ) { + return flatArr.concat( arr ); + }, [] ); + return wp.comments().forPost( pageId ).get(); + }); + }); + + it( 'returns an array of posts', function() { + return expect( commentProm ).to.eventually.be.an( 'array' ); + }); + + it( 'returns the correct number of comments', function() { + var prom = commentProm.then(function( comments ) { + expect( comments.length ).to.equal( 3 ); + expect( comments.length ).to.equal( pageComments.length ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'returns the correct comments', function() { + var prom = commentProm.then(function( comments ) { + pageComments.forEach(function( comment, i ) { + [ 'id', 'parent', 'author', 'author_name' ].forEach(function( prop ) { + expect( comment[ prop ] ).to.equal( comments[ i ][ prop ] ); + }); + expect( comment.content.rendered ).to.equal( comments[ i ].content.rendered ); + }); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + }); + +}); diff --git a/tests/unit/lib/comments.js b/tests/unit/lib/comments.js new file mode 100644 index 00000000..be5eb782 --- /dev/null +++ b/tests/unit/lib/comments.js @@ -0,0 +1,149 @@ +'use strict'; +var expect = require( 'chai' ).expect; + +var CommentsRequest = require( '../../../lib/comments' ); +var CollectionRequest = require( '../../../lib/shared/collection-request' ); +var WPRequest = require( '../../../lib/shared/wp-request' ); + +describe( 'wp.comments', function() { + + describe( 'constructor', function() { + + var comments; + + beforeEach(function() { + comments = new CommentsRequest(); + }); + + it( 'should create a CommentsRequest instance', function() { + expect( comments instanceof CommentsRequest ).to.be.true; + }); + + it( 'should set any passed-in options', function() { + comments = new CommentsRequest({ + booleanProp: true, + strProp: 'Some string' + }); + expect( comments._options.booleanProp ).to.be.true; + expect( comments._options.strProp ).to.equal( 'Some string' ); + }); + + it( 'should default _options to {}', function() { + expect( comments._options ).to.deep.equal( {} ); + }); + + it( 'should intitialize instance properties', function() { + expect( comments._path ).to.deep.equal( {} ); + expect( comments._template ).to.equal( 'comments(/:id)' ); + var _supportedMethods = comments._supportedMethods.sort().join( '|' ); + expect( _supportedMethods ).to.equal( 'get|head|post' ); + }); + + it( 'should inherit CommentsRequest from CollectionRequest', function() { + expect( comments instanceof CollectionRequest ).to.be.true; + expect( comments instanceof WPRequest ).to.be.true; + }); + + it( 'should inherit prototype methods from both ancestors', function() { + // Spot-check from CollectionRequest: + expect( comments ).to.have.property( 'param' ); + expect( comments.param ).to.be.a( 'function' ); + // From WPRequest: + expect( comments ).to.have.property( 'get' ); + expect( comments.get ).to.be.a( 'function' ); + expect( comments ).to.have.property( '_renderURI' ); + expect( comments._renderURI ).to.be.a( 'function' ); + }); + + }); + + describe( '_pathValidators', function() { + + it( 'defines validators for id and action', function() { + var comments = new CommentsRequest(); + expect( comments._pathValidators ).to.deep.equal({ + id: /^\d+$/ + }); + }); + + }); + + describe( 'query methods', function() { + + var comments; + + beforeEach(function() { + comments = new CommentsRequest(); + comments._options = { + endpoint: '/wp-json/' + }; + }); + + it( 'provides a method to set the ID', function() { + expect( comments ).to.have.property( 'id' ); + expect( comments.id ).to.be.a( 'function' ); + comments.id( 314159 ); + expect( comments._path ).to.have.property( 'id' ); + expect( comments._path.id ).to.equal( 314159 ); + }); + + it( 'parses ID parameters into integers', function() { + comments.id( '8' ); + expect( comments._path ).to.have.property( 'id' ); + expect( comments._path.id ).to.equal( 8 ); + comments.id( 4.019 ); + expect( comments._path.id ).to.equal( 4 ); + }); + + it( 'should update the supported methods when setting ID', function() { + comments.id( 8 ); + var _supportedMethods = comments._supportedMethods.sort().join( '|' ); + expect( _supportedMethods ).to.equal( 'delete|get|head|post|put' ); + }); + + }); + + describe( 'URL Generation', function() { + + var comments; + + beforeEach(function() { + comments = new CommentsRequest(); + comments._options = { + endpoint: '/wp-json/' + }; + }); + + it( 'should create the URL for retrieving all comments', function() { + var path = comments._renderURI(); + expect( path ).to.equal( '/wp-json/wp/v2/comments' ); + }); + + it( 'should create the URL for retrieving a specific comment', function() { + var path = comments.id( 1337 )._renderURI(); + expect( path ).to.equal( '/wp-json/wp/v2/comments/1337' ); + }); + + it( 'throws an error if an invalid ID is specified', function() { + expect(function numberPassesValidation() { + comments._path = { id: 8 }; + comments._renderPath(); + }).not.to.throw(); + + expect(function stringFailsValidation() { + comments._path = { id: 'wombat' }; + comments._renderPath(); + }).to.throw(); + }); + + it( 'should restrict template changes to a single instance', function() { + comments._template = 'path/with/comment/nr/:id'; + var newComments = new CommentsRequest(); + newComments._options.endpoint = 'endpoint/url/'; + var path = newComments.id( 3 )._renderURI(); + expect( path ).to.equal( 'endpoint/url/wp/v2/comments/3' ); + }); + + }); + +}); diff --git a/wp.js b/wp.js index 530ae439..6558f229 100644 --- a/wp.js +++ b/wp.js @@ -23,6 +23,7 @@ var defaults = { }; // Pull in request module constructors +var CommentsRequest = require( './lib/comments' ); var MediaRequest = require( './lib/media' ); var PagesRequest = require( './lib/pages' ); var PostsRequest = require( './lib/posts' ); @@ -82,6 +83,19 @@ WP.site = function( endpoint ) { return new WP({ endpoint: endpoint }); }; +/** + * Start a request against the `/comments` endpoint + * + * @method comments + * @param {Object} [options] An options hash for a new CommentsRequest + * @return {CommentsRequest} A CommentsRequest instance + */ +WP.prototype.comments = function( options ) { + options = options || {}; + options = extend( options, this._options ); + return new CommentsRequest( options ); +}; + /** * Start a request against the `/media` endpoint *