Added audience feedback service and storage (#15584)
fixes https://github.com/TryGhost/Team/issues/2049 fixes https://github.com/TryGhost/Team/issues/2053 - This adds a new audience feedback package to Ghost. - A new members API to give feedback on posts using the `/api/feedback` endpoint. - Added a new authentication middleware that supports both uuid-based and session based authentication.
This commit is contained in:
parent
6ff34fb49f
commit
e540344ef2
6
ghost/audience-feedback/.eslintrc.js
Normal file
6
ghost/audience-feedback/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
21
ghost/audience-feedback/README.md
Normal file
21
ghost/audience-feedback/README.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Audience Feedback
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
## Develop
|
||||
|
||||
This is a monorepo package.
|
||||
|
||||
Follow the instructions for the top-level repo.
|
||||
1. `git clone` this repo & `cd` into it as usual
|
||||
2. Run `yarn` to install top-level dependencies.
|
||||
|
||||
|
||||
|
||||
## Test
|
||||
|
||||
- `yarn lint` run just eslint
|
||||
- `yarn test` run lint and tests
|
||||
|
1
ghost/audience-feedback/index.js
Normal file
1
ghost/audience-feedback/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./lib/audience-feedback');
|
85
ghost/audience-feedback/lib/AudienceFeedbackController.js
Normal file
85
ghost/audience-feedback/lib/AudienceFeedbackController.js
Normal file
@ -0,0 +1,85 @@
|
||||
const Feedback = require('./Feedback');
|
||||
const errors = require('@tryghost/errors');
|
||||
const tpl = require('@tryghost/tpl');
|
||||
|
||||
const messages = {
|
||||
invalidScore: 'Invalid feedback score. Only 1 or 0 is currently allowed.',
|
||||
postNotFound: 'Post not found.',
|
||||
memberNotFound: 'Member not found.'
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} IFeedbackRepository
|
||||
* @prop {(feedback: Feedback) => Promise<void>} add
|
||||
* @prop {(feedback: Feedback) => Promise<void>} edit
|
||||
* @prop {(postId, memberId) => Promise<Feedback>} get
|
||||
* @prop {(id: string) => Promise<Post|undefined>} getPostById
|
||||
*/
|
||||
|
||||
class AudienceFeedbackController {
|
||||
/** @type IFeedbackRepository */
|
||||
#repository;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {IFeedbackRepository} deps.repository
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#repository = deps.repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get member from frame
|
||||
*/
|
||||
#getMember(frame) {
|
||||
if (!frame.options?.context?.member?.id) {
|
||||
// This is an internal server error because authentication should happen outside this service.
|
||||
throw new errors.InternalServerError({
|
||||
message: tpl(messages.memberNotFound)
|
||||
});
|
||||
}
|
||||
return frame.options.context.member;
|
||||
}
|
||||
|
||||
async add(frame) {
|
||||
const data = frame.data.feedback[0];
|
||||
const postId = data.post_id;
|
||||
const score = data.score;
|
||||
|
||||
if (![0, 1].includes(score)) {
|
||||
throw new errors.ValidationError({
|
||||
message: tpl(messages.invalidScore)
|
||||
});
|
||||
}
|
||||
|
||||
const member = this.#getMember(frame);
|
||||
|
||||
const post = await this.#repository.getPostById(postId);
|
||||
if (!post) {
|
||||
throw new errors.NotFoundError({
|
||||
message: tpl(messages.postNotFound)
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await this.#repository.get(post.id, member.id);
|
||||
if (existing) {
|
||||
if (existing.score === score) {
|
||||
// Don't save so we don't update the updated_at timestamp
|
||||
return existing;
|
||||
}
|
||||
existing.score = score;
|
||||
await this.#repository.edit(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const feedback = new Feedback({
|
||||
memberId: member.id,
|
||||
postId: post.id,
|
||||
score
|
||||
});
|
||||
await this.#repository.add(feedback);
|
||||
return feedback;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AudienceFeedbackController;
|
8
ghost/audience-feedback/lib/AudienceFeedbackService.js
Normal file
8
ghost/audience-feedback/lib/AudienceFeedbackService.js
Normal file
@ -0,0 +1,8 @@
|
||||
class AudienceFeedbackService {
|
||||
buildLink() {
|
||||
// todo
|
||||
return new URL('https://example.com');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AudienceFeedbackService;
|
35
ghost/audience-feedback/lib/Feedback.js
Normal file
35
ghost/audience-feedback/lib/Feedback.js
Normal file
@ -0,0 +1,35 @@
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
|
||||
module.exports = class Feedback {
|
||||
/** @type {ObjectID} */
|
||||
id;
|
||||
/** @type {number} */
|
||||
score;
|
||||
/** @type {ObjectID} */
|
||||
memberId;
|
||||
/** @type {ObjectID} */
|
||||
postId;
|
||||
|
||||
constructor(data) {
|
||||
if (!data.id) {
|
||||
this.id = new ObjectID();
|
||||
}
|
||||
|
||||
if (typeof data.id === 'string') {
|
||||
this.id = ObjectID.createFromHexString(data.id);
|
||||
}
|
||||
|
||||
this.score = data.score ?? 0;
|
||||
if (typeof data.memberId === 'string') {
|
||||
this.memberId = ObjectID.createFromHexString(data.memberId);
|
||||
} else {
|
||||
this.memberId = data.memberId;
|
||||
}
|
||||
|
||||
if (typeof data.postId === 'string') {
|
||||
this.postId = ObjectID.createFromHexString(data.postId);
|
||||
} else {
|
||||
this.postId = data.postId;
|
||||
}
|
||||
}
|
||||
};
|
5
ghost/audience-feedback/lib/audience-feedback.js
Normal file
5
ghost/audience-feedback/lib/audience-feedback.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
AudienceFeedbackService: require('./AudienceFeedbackService'),
|
||||
AudienceFeedbackController: require('./AudienceFeedbackController'),
|
||||
Feedback: require('./Feedback')
|
||||
};
|
29
ghost/audience-feedback/package.json
Normal file
29
ghost/audience-feedback/package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@tryghost/audience-feedback",
|
||||
"version": "0.0.0",
|
||||
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/audience-feedback",
|
||||
"author": "Ghost Foundation",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "echo \"Implement me!\"",
|
||||
"test:unit": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
|
||||
"test": "yarn test:unit",
|
||||
"lint:code": "eslint *.js lib/ --ext .js --cache",
|
||||
"lint": "yarn lint:code && yarn lint:test",
|
||||
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"lib"
|
||||
],
|
||||
"devDependencies": {
|
||||
"c8": "7.12.0",
|
||||
"mocha": "10.0.0",
|
||||
"should": "13.2.3",
|
||||
"sinon": "14.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/errors": "1.2.17"
|
||||
}
|
||||
}
|
6
ghost/audience-feedback/test/.eslintrc.js
Normal file
6
ghost/audience-feedback/test/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
10
ghost/audience-feedback/test/hello.test.js
Normal file
10
ghost/audience-feedback/test/hello.test.js
Normal file
@ -0,0 +1,10 @@
|
||||
// Switch these lines once there are useful utils
|
||||
// const testUtils = require('./utils');
|
||||
require('./utils');
|
||||
|
||||
describe('Hello world', function () {
|
||||
it('Runs a test', function () {
|
||||
// TODO: Write me!
|
||||
'hello'.should.eql('hello');
|
||||
});
|
||||
});
|
11
ghost/audience-feedback/test/utils/assertions.js
Normal file
11
ghost/audience-feedback/test/utils/assertions.js
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Custom Should Assertions
|
||||
*
|
||||
* Add any custom assertions to this file.
|
||||
*/
|
||||
|
||||
// Example Assertion
|
||||
// should.Assertion.add('ExampleAssertion', function () {
|
||||
// this.params = {operator: 'to be a valid Example Assertion'};
|
||||
// this.obj.should.be.an.Object;
|
||||
// });
|
11
ghost/audience-feedback/test/utils/index.js
Normal file
11
ghost/audience-feedback/test/utils/index.js
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Test Utilities
|
||||
*
|
||||
* Shared utils for writing tests
|
||||
*/
|
||||
|
||||
// Require overrides - these add globals for tests
|
||||
require('./overrides');
|
||||
|
||||
// Require assertions - adds custom should assertions
|
||||
require('./assertions');
|
10
ghost/audience-feedback/test/utils/overrides.js
Normal file
10
ghost/audience-feedback/test/utils/overrides.js
Normal file
@ -0,0 +1,10 @@
|
||||
// This file is required before any test is run
|
||||
|
||||
// Taken from the should wiki, this is how to make should global
|
||||
// Should is a global in our eslint test config
|
||||
global.should = require('should').noConflict();
|
||||
should.extend();
|
||||
|
||||
// Sinon is a simple case
|
||||
// Sinon is a global in our eslint test config
|
||||
global.sinon = require('sinon');
|
@ -288,6 +288,7 @@ async function initServices({config}) {
|
||||
const memberAttribution = require('./server/services/member-attribution');
|
||||
const membersEvents = require('./server/services/members-events');
|
||||
const linkTracking = require('./server/services/link-tracking');
|
||||
const audienceFeedback = require('./server/services/audience-feedback');
|
||||
|
||||
const urlUtils = require('./shared/url-utils');
|
||||
|
||||
@ -315,7 +316,8 @@ async function initServices({config}) {
|
||||
apiUrl: urlUtils.urlFor('api', {type: 'admin'}, true)
|
||||
}),
|
||||
comments.init(),
|
||||
linkTracking.init()
|
||||
linkTracking.init(),
|
||||
audienceFeedback.init()
|
||||
]);
|
||||
debug('End: Services');
|
||||
|
||||
|
23
ghost/core/core/server/api/endpoints/feedback-members.js
Normal file
23
ghost/core/core/server/api/endpoints/feedback-members.js
Normal file
@ -0,0 +1,23 @@
|
||||
const feedbackService = require('../../services/audience-feedback');
|
||||
|
||||
module.exports = {
|
||||
docName: 'feedback',
|
||||
|
||||
add: {
|
||||
statusCode: 201,
|
||||
validation: {
|
||||
data: {
|
||||
post_id: {
|
||||
required: true
|
||||
},
|
||||
score: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: false,
|
||||
query(frame) {
|
||||
return feedbackService.controller.add(frame);
|
||||
}
|
||||
}
|
||||
};
|
@ -231,5 +231,9 @@ module.exports = {
|
||||
|
||||
get commentsMembers() {
|
||||
return apiFramework.pipeline(require('./comments-members'), localUtils, 'members');
|
||||
},
|
||||
|
||||
get feedbackMembers() {
|
||||
return apiFramework.pipeline(require('./feedback-members'), localUtils, 'members');
|
||||
}
|
||||
};
|
||||
|
@ -0,0 +1,67 @@
|
||||
module.exports = class FeedbackRepository {
|
||||
/** @type {object} */
|
||||
#Member;
|
||||
|
||||
/** @type {object} */
|
||||
#Post;
|
||||
|
||||
/** @type {object} */
|
||||
#MemberFeedback;
|
||||
|
||||
/** @type {typeof Object} */
|
||||
#Feedback;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {object} deps.Member Bookshelf Model
|
||||
* @param {object} deps.Post Bookshelf Model
|
||||
* @param {object} deps.MemberFeedback Bookshelf Model
|
||||
* @param {object} deps.Feedback Feedback object
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#Member = deps.Member;
|
||||
this.#Post = deps.Post;
|
||||
this.#MemberFeedback = deps.MemberFeedback;
|
||||
this.#Feedback = deps.Feedback;
|
||||
}
|
||||
|
||||
async add(feedback) {
|
||||
await this.#MemberFeedback.add({
|
||||
id: feedback.id.toHexString(),
|
||||
member_id: feedback.memberId.toHexString(),
|
||||
post_id: feedback.postId.toHexString(),
|
||||
score: feedback.score
|
||||
});
|
||||
}
|
||||
|
||||
async edit(feedback) {
|
||||
await this.#MemberFeedback.edit({
|
||||
score: feedback.score
|
||||
}, {
|
||||
id: feedback.id.toHexString()
|
||||
});
|
||||
}
|
||||
|
||||
async get(postId, memberId) {
|
||||
const model = await this.#MemberFeedback.findOne({member_id: memberId, post_id: postId}, {require: false});
|
||||
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new this.#Feedback({
|
||||
id: model.id,
|
||||
memberId: model.get('member_id'),
|
||||
postId: model.get('post_id'),
|
||||
score: model.get('score')
|
||||
});
|
||||
}
|
||||
|
||||
async getMemberByUuid(uuid) {
|
||||
return await this.#Member.findOne({uuid});
|
||||
}
|
||||
|
||||
async getPostById(id) {
|
||||
return await this.#Post.findOne({id});
|
||||
}
|
||||
};
|
28
ghost/core/core/server/services/audience-feedback/index.js
Normal file
28
ghost/core/core/server/services/audience-feedback/index.js
Normal file
@ -0,0 +1,28 @@
|
||||
const FeedbackRepository = require('./FeedbackRepository');
|
||||
|
||||
class AudienceFeedbackServiceWrapper {
|
||||
async init() {
|
||||
if (this.service) {
|
||||
// Already done
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire up all the dependencies
|
||||
const models = require('../../models');
|
||||
|
||||
const {AudienceFeedbackService, AudienceFeedbackController, Feedback} = require('@tryghost/audience-feedback');
|
||||
|
||||
this.repository = new FeedbackRepository({
|
||||
Member: models.Member,
|
||||
MemberFeedback: models.MemberFeedback,
|
||||
Feedback,
|
||||
Post: models.Post
|
||||
});
|
||||
|
||||
// Expose the service
|
||||
this.service = new AudienceFeedbackService();
|
||||
this.controller = new AudienceFeedbackController({repository: this.repository});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AudienceFeedbackServiceWrapper();
|
@ -5,6 +5,13 @@ const models = require('../../models');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
const spamPrevention = require('../../web/shared/middleware/api/spam-prevention');
|
||||
const {formattedMemberResponse} = require('./utils');
|
||||
const errors = require('@tryghost/errors');
|
||||
const tpl = require('@tryghost/tpl');
|
||||
|
||||
const messages = {
|
||||
missingUuid: 'Missing uuid.',
|
||||
invalidUuid: 'Invalid uuid.'
|
||||
};
|
||||
|
||||
// @TODO: This piece of middleware actually belongs to the frontend, not to the member app
|
||||
// Need to figure a way to separate these things (e.g. frontend actually talks to members API)
|
||||
@ -20,6 +27,38 @@ const loadMemberSession = async function (req, res, next) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Require member authentication, and make it possible to authenticate via uuid.
|
||||
* You can chain this after loadMemberSession to make it possible to authetnicate via both the uuid and the session.
|
||||
*/
|
||||
const authMemberByUuid = async function (req, res, next) {
|
||||
try {
|
||||
if (res.locals.member && req.member) {
|
||||
// Already authenticated via session
|
||||
return next();
|
||||
}
|
||||
|
||||
const uuid = req.query.uuid;
|
||||
if (!uuid) {
|
||||
throw new errors.UnauthorizedError({
|
||||
messsage: tpl(messages.missingUuid)
|
||||
});
|
||||
}
|
||||
|
||||
const member = await membersService.api.memberBREADService.read({uuid});
|
||||
if (!member) {
|
||||
throw new errors.UnauthorizedError({
|
||||
message: tpl(messages.invalidUuid)
|
||||
});
|
||||
}
|
||||
Object.assign(req, {member});
|
||||
res.locals.member = req.member;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const getIdentityToken = async function (req, res) {
|
||||
try {
|
||||
const token = await membersService.ssr.getIdentityTokenForMemberFromSession(req, res);
|
||||
@ -216,6 +255,7 @@ const createSessionFromMagicLink = async function (req, res, next) {
|
||||
// Set req.member & res.locals.member if a cookie is set
|
||||
module.exports = {
|
||||
loadMemberSession,
|
||||
authMemberByUuid,
|
||||
createSessionFromMagicLink,
|
||||
getIdentityToken,
|
||||
getMemberNewsletters,
|
||||
|
@ -10,6 +10,8 @@ const shared = require('../shared');
|
||||
const labs = require('../../../shared/labs');
|
||||
const errorHandler = require('@tryghost/mw-error-handler');
|
||||
const config = require('../../../shared/config');
|
||||
const {http} = require('@tryghost/api-framework');
|
||||
const api = require('../../api').endpoints;
|
||||
|
||||
const commentRouter = require('../comments');
|
||||
|
||||
@ -65,6 +67,16 @@ module.exports = function setupMembersApp() {
|
||||
// Comments
|
||||
membersApp.use('/api/comments', commentRouter());
|
||||
|
||||
// Feedback
|
||||
membersApp.post(
|
||||
'/api/feedback',
|
||||
labs.enabledMiddleware('audienceFeedback'),
|
||||
bodyParser.json({limit: '50mb'}),
|
||||
middleware.loadMemberSession,
|
||||
middleware.authMemberByUuid,
|
||||
http(api.feedbackMembers.add)
|
||||
);
|
||||
|
||||
// API error handling
|
||||
membersApp.use('/api', errorHandler.resourceNotFound);
|
||||
membersApp.use('/api', errorHandler.handleJSONResponse(sentry));
|
||||
|
@ -0,0 +1,265 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Members Feedback Authentication Allows authentication via session 1: [body] 1`] = `
|
||||
Object {
|
||||
"feedback": Array [
|
||||
Object {
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"memberId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"postId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"score": 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Authentication Allows authentication via session 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "132",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/feedback\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||
"vary": "Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can add feedback 1: [body] 1`] = `
|
||||
Object {
|
||||
"feedback": Array [
|
||||
Object {
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"memberId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"postId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"type": "positive",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can add feedback 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "140",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/feedback\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||
"vary": "Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can add positive feedback 1: [body] 1`] = `
|
||||
Object {
|
||||
"feedback": Array [
|
||||
Object {
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"memberId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"postId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"score": 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can add positive feedback 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "132",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/feedback\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||
"vary": "Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can change existing feedback 1: [body] 1`] = `
|
||||
Object {
|
||||
"feedback": Array [
|
||||
Object {
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"memberId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"postId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"score": 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can change existing feedback 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "132",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/feedback\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||
"vary": "Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can change existing feedback 3: [body] 1`] = `
|
||||
Object {
|
||||
"feedback": Array [
|
||||
Object {
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"memberId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"postId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"score": 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can change existing feedback 4: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "132",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/feedback\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||
"vary": "Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can change existing feedback 5: [body] 1`] = `
|
||||
Object {
|
||||
"feedback": Array [
|
||||
Object {
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"memberId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"postId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"score": 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Can change existing feedback 6: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "132",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/feedback\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||
"vary": "Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Validation Throws for invalid score 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": "Invalid feedback score. Only 1 or 0 is currently allowed.",
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "Validation error, cannot save feedback.",
|
||||
"property": null,
|
||||
"type": "ValidationError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Validation Throws for invalid score type 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": "Invalid feedback score. Only 1 or 0 is currently allowed.",
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "Validation error, cannot save feedback.",
|
||||
"property": null,
|
||||
"type": "ValidationError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Validation Throws for invalid type 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": "Invalid feedback type",
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "Validation error, cannot save feedback.",
|
||||
"property": null,
|
||||
"type": "ValidationError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Validation Throws for invalid uuid 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": null,
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "Invalid uuid.",
|
||||
"property": null,
|
||||
"type": "UnauthorizedError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Validation Throws for nonexisting post 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": "Post not found.",
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "Resource not found error, cannot save feedback.",
|
||||
"property": null,
|
||||
"type": "NotFoundError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members Feedback Validation Throws for nonexisting uuid 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": null,
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "Invalid uuid.",
|
||||
"property": null,
|
||||
"type": "UnauthorizedError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
282
ghost/core/test/e2e-api/members/feedback.test.js
Normal file
282
ghost/core/test/e2e-api/members/feedback.test.js
Normal file
@ -0,0 +1,282 @@
|
||||
const assert = require('assert');
|
||||
const {agentProvider, mockManager, fixtureManager, matchers, configUtils} = require('../../utils/e2e-framework');
|
||||
const {anyEtag, anyObjectId, anyLocationFor, anyErrorId} = matchers;
|
||||
const models = require('../../../core/server/models');
|
||||
const sinon = require('sinon');
|
||||
|
||||
describe('Members Feedback', function () {
|
||||
let membersAgent, membersAgent2, memberUuid;
|
||||
let clock;
|
||||
|
||||
before(async function () {
|
||||
membersAgent = await agentProvider.getMembersAPIAgent();
|
||||
membersAgent2 = await agentProvider.getMembersAPIAgent();
|
||||
|
||||
await fixtureManager.init('posts', 'members');
|
||||
memberUuid = fixtureManager.get('members', 0).uuid;
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
mockManager.mockMail();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
clock?.restore();
|
||||
clock = undefined;
|
||||
configUtils.restore();
|
||||
mockManager.restore();
|
||||
});
|
||||
|
||||
describe('Authentication', function () {
|
||||
it('Allows authentication via session', async function () {
|
||||
const postId = fixtureManager.get('posts', 0).id;
|
||||
await membersAgent2.loginAs('authenticationtest@email.com');
|
||||
|
||||
await membersAgent2
|
||||
.post('/api/feedback/')
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 1,
|
||||
post_id: postId
|
||||
}]
|
||||
})
|
||||
.expectStatus(201)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag,
|
||||
location: anyLocationFor('feedback')
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
feedback: [
|
||||
{
|
||||
id: anyObjectId,
|
||||
memberId: anyObjectId,
|
||||
postId: anyObjectId
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation', function () {
|
||||
const postId = fixtureManager.get('posts', 0).id;
|
||||
|
||||
it('Throws for invalid score', async function () {
|
||||
await membersAgent
|
||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 2,
|
||||
post_id: postId
|
||||
}]
|
||||
})
|
||||
.expectStatus(422)
|
||||
.matchBodySnapshot({
|
||||
errors: [
|
||||
{
|
||||
id: anyErrorId
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('Throws for invalid score type', async function () {
|
||||
await membersAgent
|
||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 'text',
|
||||
post_id: postId
|
||||
}]
|
||||
})
|
||||
.expectStatus(422)
|
||||
.matchBodySnapshot({
|
||||
errors: [
|
||||
{
|
||||
id: anyErrorId
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('Throws for invalid uuid', async function () {
|
||||
await membersAgent
|
||||
.post(`/api/feedback/?uuid=1234`)
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 1,
|
||||
post_id: postId
|
||||
}]
|
||||
})
|
||||
.expectStatus(401)
|
||||
.matchBodySnapshot({
|
||||
errors: [
|
||||
{
|
||||
id: anyErrorId
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('Throws for nonexisting uuid', async function () {
|
||||
const uuid = '00000000-0000-0000-0000-000000000000';
|
||||
await membersAgent
|
||||
.post(`/api/feedback/?uuid=${uuid}`)
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 1,
|
||||
post_id: postId
|
||||
}]
|
||||
})
|
||||
.expectStatus(401)
|
||||
.matchBodySnapshot({
|
||||
errors: [
|
||||
{
|
||||
id: anyErrorId
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('Throws for nonexisting post', async function () {
|
||||
await membersAgent
|
||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 1,
|
||||
post_id: '123'
|
||||
}]
|
||||
})
|
||||
.expectStatus(404)
|
||||
.matchBodySnapshot({
|
||||
errors: [
|
||||
{
|
||||
id: anyErrorId
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Can add positive feedback', async function () {
|
||||
const postId = fixtureManager.get('posts', 0).id;
|
||||
|
||||
await membersAgent
|
||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 1,
|
||||
post_id: postId
|
||||
}]
|
||||
})
|
||||
.expectStatus(201)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag,
|
||||
location: anyLocationFor('feedback')
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
feedback: [
|
||||
{
|
||||
id: anyObjectId,
|
||||
memberId: anyObjectId,
|
||||
postId: anyObjectId
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('Can change existing feedback', async function () {
|
||||
clock = sinon.useFakeTimers(new Date());
|
||||
const postId = fixtureManager.get('posts', 1).id;
|
||||
|
||||
const {body} = await membersAgent
|
||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 0,
|
||||
post_id: postId
|
||||
}]
|
||||
})
|
||||
.expectStatus(201)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag,
|
||||
location: anyLocationFor('feedback')
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
feedback: [
|
||||
{
|
||||
id: anyObjectId,
|
||||
memberId: anyObjectId,
|
||||
postId: anyObjectId
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert.equal(body.feedback[0].score, 0);
|
||||
const feedbackId = body.feedback[0].id;
|
||||
|
||||
// Fetch real model
|
||||
const model1 = await models.MemberFeedback.findOne({id: feedbackId}, {require: true});
|
||||
|
||||
clock.tick(10 * 60 * 1000);
|
||||
|
||||
const {body: body2} = await membersAgent
|
||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 1,
|
||||
post_id: postId
|
||||
}]
|
||||
})
|
||||
.expectStatus(201)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag,
|
||||
location: anyLocationFor('feedback')
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
feedback: [
|
||||
{
|
||||
id: anyObjectId,
|
||||
memberId: anyObjectId,
|
||||
postId: anyObjectId
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert.equal(body2.feedback[0].id, feedbackId);
|
||||
assert.equal(body2.feedback[0].score, 1);
|
||||
const model2 = await models.MemberFeedback.findOne({id: feedbackId}, {require: true});
|
||||
|
||||
assert.notEqual(model2.get('updated_at').getTime(), model1.get('updated_at').getTime());
|
||||
|
||||
clock.tick(10 * 60 * 1000);
|
||||
|
||||
// Do the same change again, and the model shouldn't change
|
||||
const {body: body3} = await membersAgent
|
||||
.post(`/api/feedback/?uuid=${memberUuid}`)
|
||||
.body({
|
||||
feedback: [{
|
||||
score: 1,
|
||||
post_id: postId
|
||||
}]
|
||||
})
|
||||
.expectStatus(201)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag,
|
||||
location: anyLocationFor('feedback')
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
feedback: [
|
||||
{
|
||||
id: anyObjectId,
|
||||
memberId: anyObjectId,
|
||||
postId: anyObjectId
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const model3 = await models.MemberFeedback.findOne({id: feedbackId}, {require: true});
|
||||
assert.equal(body3.feedback[0].id, feedbackId);
|
||||
assert.equal(body3.feedback[0].score, 1);
|
||||
assert.equal(model3.get('updated_at').getTime(), model2.get('updated_at').getTime());
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user