0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-15 03:01:37 -05:00

Added members POST API (#11189)

no issue

- Added Regression full test coverage for members Admin API
- Added `POST /members` endpoint
- Added members schema definition + validation
- Added ability to pass through send_email/emal_type options to members API
This commit is contained in:
Naz Gargol 2019-10-03 11:15:50 +02:00 committed by GitHub
parent 839cf0289f
commit 5228d9819b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 747 additions and 0 deletions

View file

@ -40,6 +40,34 @@ const members = {
}
},
add: {
statusCode: 201,
headers: {},
options: [
'send_email',
'email_type'
],
validation: {
data: {
email: {required: true}
},
options: {
email_type: {
values: ['signin', 'signup', 'subscribe']
}
}
},
permissions: true,
async query(frame) {
const member = await membersService.api.members.create(frame.data.members[0], {
sendEmail: frame.options.send_email,
emailType: frame.options.email_type
});
return member;
}
},
destroy: {
statusCode: 204,
headers: {},

View file

@ -8,6 +8,14 @@ module.exports = {
frame.response = data;
},
add(data, apiConfig, frame) {
debug('add');
frame.response = {
members: [data]
};
},
read(data, apiConfig, frame) {
debug('read');

View file

@ -23,6 +23,10 @@ module.exports = {
return require('./invitations');
},
get members() {
return require('./members');
},
get settings() {
return require('./settings');
},

View file

@ -0,0 +1,9 @@
const jsonSchema = require('../utils/json-schema');
module.exports = {
add(apiConfig, frame) {
const schema = require('./schemas/members-add');
const definitions = require('./schemas/members');
return jsonSchema.validate(schema, definitions, frame.data);
}
};

View file

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "members.add",
"title": "members.add",
"description": "Schema for members.add",
"type": "object",
"additionalProperties": false,
"properties": {
"members": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"allOf": [{"$ref": "members#/definitions/member"}],
"required": ["email"]
}
}
},
"required": ["members"]
}

View file

@ -0,0 +1,42 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "members",
"title": "members",
"description": "Base members definitions",
"definitions": {
"member": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"email": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"id": {
"strip": true
},
"created_at": {
"strip": true
},
"created_by": {
"strip": true
},
"updated_at": {
"strip": true
},
"updated_by": {
"strip": true
}
}
}
}
}

View file

@ -40,6 +40,34 @@ const members = {
}
},
add: {
statusCode: 201,
headers: {},
options: [
'send_email',
'email_type'
],
validation: {
data: {
email: {required: true}
},
options: {
email_type: {
values: ['signin', 'signup', 'subscribe']
}
}
},
permissions: true,
async query(frame) {
const member = await membersService.api.members.create(frame.data.members[0], {
sendEmail: frame.options.send_email,
emailType: frame.options.email_type
});
return member;
}
},
destroy: {
statusCode: 204,
headers: {},

View file

@ -8,6 +8,14 @@ module.exports = {
frame.response = data;
},
add(data, apiConfig, frame) {
debug('add');
frame.response = {
members: [data]
};
},
read(data, apiConfig, frame) {
debug('read');

View file

@ -23,6 +23,10 @@ module.exports = {
return require('./invitations');
},
get members() {
return require('./members');
},
get settings() {
return require('./settings');
},

View file

@ -0,0 +1,9 @@
const jsonSchema = require('../utils/json-schema');
module.exports = {
add(apiConfig, frame) {
const schema = require('./schemas/members-add');
const definitions = require('./schemas/members');
return jsonSchema.validate(schema, definitions, frame.data);
}
};

View file

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "members.add",
"title": "members.add",
"description": "Schema for members.add",
"type": "object",
"additionalProperties": false,
"properties": {
"members": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"allOf": [{"$ref": "members#/definitions/member"}],
"required": ["email"]
}
}
},
"required": ["members"]
}

View file

@ -0,0 +1,42 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "members",
"title": "members",
"description": "Base members definitions",
"definitions": {
"member": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"email": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"id": {
"strip": true
},
"created_at": {
"strip": true
},
"created_by": {
"strip": true
},
"updated_at": {
"strip": true
},
"updated_by": {
"strip": true
}
}
}
}
}

View file

@ -103,6 +103,7 @@ module.exports = function apiRoutes() {
// ## Members
router.get('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.browse));
router.post('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.add));
router.get('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.read));
router.del('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.destroy));

View file

@ -103,6 +103,7 @@ module.exports = function apiRoutes() {
// ## Members
router.get('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.browse));
router.post('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.add));
router.get('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.read));
router.del('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.destroy));

View file

@ -0,0 +1,244 @@
const path = require('path');
const should = require('should');
const supertest = require('supertest');
const sinon = require('sinon');
const testUtils = require('../../../../utils');
const localUtils = require('./utils');
const config = require('../../../../../server/config');
const labs = require('../../../../../server/services/labs');
const ghost = testUtils.startGhost;
let request;
describe('Members API', function () {
before(function () {
sinon.stub(labs, 'isSet').withArgs('members').returns(true);
});
after(function () {
sinon.restore();
});
before(function () {
return ghost()
.then(function () {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'member');
});
});
it('Can browse', function () {
return request
.get(localUtils.API.getApiQuery('members/'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
testUtils.API.isISO8601(jsonResponse.members[0].created_at).should.be.true();
jsonResponse.members[0].created_at.should.be.an.instanceof(String);
jsonResponse.meta.pagination.should.have.property('page', 1);
jsonResponse.meta.pagination.should.have.property('limit', 15);
jsonResponse.meta.pagination.should.have.property('pages', 1);
jsonResponse.meta.pagination.should.have.property('total', 1);
jsonResponse.meta.pagination.should.have.property('next', null);
jsonResponse.meta.pagination.should.have.property('prev', null);
});
});
it('Can read', function () {
return request
.get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[0].id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe');
});
});
it('Can add', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
jsonResponse.members[0].name.should.equal(member.name);
jsonResponse.members[0].email.should.equal(member.email);
});
});
it('Should fail when passing incorrect email_type query parameter', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/?send_email=true&email_type=lel`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(422);
});
it.skip('Can edit by id', function () {
const memberToChange = {
name: 'changed',
email: 'member1Changed@test.com'
};
const memberChanged = {
name: 'changed',
email: 'member1Changed@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [memberToChange]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.put(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.send({members: [memberChanged]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
jsonResponse.members[0].name.should.equal(memberChanged.name);
jsonResponse.members[0].email.should.equal(memberChanged.email);
});
});
});
it('Can destroy', function () {
const member = {
name: 'test',
email: 'memberTestDestroy@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.delete(localUtils.API.getApiQuery(`members/${newMember.id}`))
.set('Origin', config.get('url'))
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(204)
.then(() => newMember);
})
.then((newMember) => {
return request
.get(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(404);
});
});
it.skip('Can export CSV', function () {
return request
.get(localUtils.API.getApiQuery(`members/csv/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /text\/csv/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
res.headers['content-disposition'].should.match(/Attachment;\sfilename="members/);
res.text.should.match(/id,email,created_at,deleted_at/);
res.text.should.match(/member1@test.com/);
});
});
it.skip('Can import CSV', function () {
return request
.post(localUtils.API.getApiQuery(`members/csv/`))
.attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/single-column-with-header.csv'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.meta);
should.exist(jsonResponse.meta.stats);
jsonResponse.meta.stats.imported.should.equal(2);
jsonResponse.meta.stats.duplicates.should.equal(0);
jsonResponse.meta.stats.invalid.should.equal(0);
});
});
});

View file

@ -49,6 +49,9 @@ const expectedProperties = {
subscriber: _(schema.subscribers)
.keys()
,
member: _(schema.members)
.keys()
,
accesstoken: _(schema.accesstokens)
.keys()
,

View file

@ -0,0 +1,244 @@
const path = require('path');
const should = require('should');
const supertest = require('supertest');
const sinon = require('sinon');
const testUtils = require('../../../../utils');
const localUtils = require('./utils');
const config = require('../../../../../server/config');
const labs = require('../../../../../server/services/labs');
const ghost = testUtils.startGhost;
let request;
describe('Members API', function () {
before(function () {
sinon.stub(labs, 'isSet').withArgs('members').returns(true);
});
after(function () {
sinon.restore();
});
before(function () {
return ghost()
.then(function () {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'member');
});
});
it('Can browse', function () {
return request
.get(localUtils.API.getApiQuery('members/'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
testUtils.API.isISO8601(jsonResponse.members[0].created_at).should.be.true();
jsonResponse.members[0].created_at.should.be.an.instanceof(String);
jsonResponse.meta.pagination.should.have.property('page', 1);
jsonResponse.meta.pagination.should.have.property('limit', 15);
jsonResponse.meta.pagination.should.have.property('pages', 1);
jsonResponse.meta.pagination.should.have.property('total', 1);
jsonResponse.meta.pagination.should.have.property('next', null);
jsonResponse.meta.pagination.should.have.property('prev', null);
});
});
it('Can read', function () {
return request
.get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[0].id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe');
});
});
it('Can add', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
jsonResponse.members[0].name.should.equal(member.name);
jsonResponse.members[0].email.should.equal(member.email);
});
});
it('Should fail when passing incorrect email_type query parameter', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/?send_email=true&email_type=lel`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(422);
});
it.skip('Can edit by id', function () {
const memberToChange = {
name: 'changed',
email: 'member1Changed@test.com'
};
const memberChanged = {
name: 'changed',
email: 'member1Changed@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [memberToChange]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.put(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.send({members: [memberChanged]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
jsonResponse.members[0].name.should.equal(memberChanged.name);
jsonResponse.members[0].email.should.equal(memberChanged.email);
});
});
});
it('Can destroy', function () {
const member = {
name: 'test',
email: 'memberTestDestroy@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.delete(localUtils.API.getApiQuery(`members/${newMember.id}`))
.set('Origin', config.get('url'))
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(204)
.then(() => newMember);
})
.then((newMember) => {
return request
.get(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(404);
});
});
it.skip('Can export CSV', function () {
return request
.get(localUtils.API.getApiQuery(`members/csv/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /text\/csv/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
res.headers['content-disposition'].should.match(/Attachment;\sfilename="members/);
res.text.should.match(/id,email,created_at,deleted_at/);
res.text.should.match(/member1@test.com/);
});
});
it.skip('Can import CSV', function () {
return request
.post(localUtils.API.getApiQuery(`members/csv/`))
.attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/single-column-with-header.csv'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.meta);
should.exist(jsonResponse.meta.stats);
jsonResponse.meta.stats.imported.should.equal(2);
jsonResponse.meta.stats.duplicates.should.equal(0);
jsonResponse.meta.stats.invalid.should.equal(0);
});
});
});

View file

@ -50,6 +50,9 @@ const expectedProperties = {
subscriber: _(schema.subscribers)
.keys()
,
member: _(schema.members)
.keys()
,
accesstoken: _(schema.accesstokens)
.keys()
,

View file

@ -361,6 +361,18 @@ DataGenerator.Content = {
}
],
members: [
{
id: ObjectId.generate(),
email: 'member1@test.com',
name: 'Mr Egg'
},
{
id: ObjectId.generate(),
email: 'member2@test.com'
}
],
webhooks: [
{
id: ObjectId.generate(),
@ -614,6 +626,15 @@ DataGenerator.forKnex = (function () {
});
}
function createMember(overrides) {
const newObj = _.cloneDeep(overrides);
return _.defaults(newObj, {
id: ObjectId.generate(),
email: 'member@ghost.org'
});
}
function createSetting(overrides) {
const newObj = _.cloneDeep(overrides);
@ -920,6 +941,7 @@ DataGenerator.forKnex = (function () {
createAppSetting: createAppSetting,
createToken: createToken,
createSubscriber: createSubscriber,
createMember: createMember,
createInvite: createInvite,
createTrustedDomain: createTrustedDomain,
createWebhook: createWebhook,

View file

@ -583,6 +583,9 @@ toDoList = {
subscriber: function insertSubscriber() {
return fixtures.insertOne('Subscriber', 'subscribers', 'createSubscriber');
},
member: function insertMember() {
return fixtures.insertOne('Member', 'members', 'createMember');
},
posts: function insertPostsAndTags() {
return fixtures.insertPostsAndTags();
},