v3: port coupons

This commit is contained in:
Matteo Pagliazzi
2016-04-02 16:37:55 +02:00
parent 731ac86244
commit de74fae0b4
12 changed files with 475 additions and 73 deletions

View File

@@ -142,5 +142,11 @@
"cardTypeRequired": "Card type required", "cardTypeRequired": "Card type required",
"cardTypeNotAllowed": "Unkown card type.", "cardTypeNotAllowed": "Unkown card type.",
"mysteryItemIsEmpty": "Mystery items are empty", "mysteryItemIsEmpty": "Mystery items are empty",
"mysteryItemOpened": "Mystery item opened." "mysteryItemOpened": "Mystery item opened.",
"invalidCoupon": "Invalid coupon code.",
"couponUsed": "Coupon code already used.",
"noSudoAccess": "You don't have sudo access.",
"couponCodeRequired": "The coupon code is required.",
"eventRequired": "\"req.params.event\" is required.",
"countRequired": "\"req.query.count\" is required."
} }

View File

@@ -3,12 +3,7 @@ import eslint from 'gulp-eslint';
const SERVER_FILES = [ const SERVER_FILES = [
'./website/src/**/api-v3/**/*.js', './website/src/**/api-v3/**/*.js',
'./website/src/models/user.js', './website/src/models/**',
'./website/src/models/task.js',
'./website/src/models/group.js',
'./website/src/models/challenge.js',
'./website/src/models/tag.js',
'./website/src/models/emailUnsubscription.js',
'./website/src/server.js', './website/src/server.js',
]; ];
const COMMON_FILES = [ const COMMON_FILES = [

View File

@@ -0,0 +1,39 @@
import {
generateUser,
translate as t,
resetHabiticaDB,
} from '../../../../helpers/api-v3-integration.helper';
describe('GET /coupons/', () => {
let user;
before(async () => {
await resetHabiticaDB();
});
beforeEach(async () => {
user = await generateUser();
});
it('returns an error if user has no sudo permission', async () => {
await user.get('/user'); // needed so the request after this will authenticate with the correct cookie session
await expect(user.get(`/coupons`)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('noSudoAccess'),
});
});
it('should return the coupons in CSV format ordered by creation date', async () => {
await user.update({
'contributor.sudo': true,
});
let coupons = await user.post('/coupons/generate/wondercon?count=11');
let res = await user.get(`/coupons`);
let splitRes = res.split('\n');
expect(splitRes.length).to.equal(13);
expect(splitRes[0]).to.equal('code,event,date,user');
expect(splitRes[6].split(',')[1]).to.equal(coupons[5].event);
});
});

View File

@@ -0,0 +1,62 @@
import {
generateUser,
translate as t,
resetHabiticaDB,
} from '../../../../helpers/api-v3-integration.helper';
describe('POST /coupons/enter/:code', () => {
let user;
let sudoUser;
before(async () => {
await resetHabiticaDB();
});
beforeEach(async () => {
user = await generateUser();
sudoUser = await generateUser({
'contributor.sudo': true,
});
});
it('returns an error if code is missing', async () => {
await expect(user.post(`/coupons/enter`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: 'Not found.',
});
});
it('returns an error if code is invalid', async () => {
await expect(user.post(`/coupons/enter/notValid`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidCoupon'),
});
});
it('returns an error if coupon has been used', async () => {
let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1');
await user.post(`/coupons/enter/${coupon._id}`); // use coupon
await expect(user.post(`/coupons/enter/${coupon._id}`)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('couponUsed'),
});
});
it('should apply the coupon to the user', async () => {
let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1');
let userRes = await user.post(`/coupons/enter/${coupon._id}`);
expect(userRes._id).to.equal(user._id);
expect(userRes.items.gear.owned.eyewear_special_wondercon_red).to.be.true;
expect(userRes.items.gear.owned.eyewear_special_wondercon_black).to.be.true;
expect(userRes.items.gear.owned.back_special_wondercon_black).to.be.true;
expect(userRes.items.gear.owned.back_special_wondercon_red).to.be.true;
expect(userRes.items.gear.owned.body_special_wondercon_red).to.be.true;
expect(userRes.items.gear.owned.body_special_wondercon_black).to.be.true;
expect(userRes.items.gear.owned.body_special_wondercon_gold).to.be.true;
expect(userRes.extra).to.eql({signupEvent: 'wondercon'});
});
});

View File

@@ -0,0 +1,66 @@
import {
generateUser,
translate as t,
resetHabiticaDB,
} from '../../../../helpers/api-v3-integration.helper';
import couponCode from 'coupon-code';
describe('POST /coupons/generate/:event', () => {
let user;
before(async () => {
await resetHabiticaDB();
});
beforeEach(async () => {
user = await generateUser({
'contributor.sudo': true,
});
});
it('returns an error if user has no sudo permission', async () => {
await user.update({
'contributor.sudo': false,
});
await expect(user.post(`/coupons/generate/aaa`)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('noSudoAccess'),
});
});
it('returns an error if event is missing', async () => {
await expect(user.post(`/coupons/generate`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: 'Not found.',
});
});
it('returns an error if event is invalid', async () => {
await expect(user.post(`/coupons/generate/notValid?count=1`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Coupon validation failed',
});
});
it('returns an error if count is missing', async () => {
await expect(user.post(`/coupons/generate/notValid`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('should generate coupons', async () => {
await user.update({
'contributor.sudo': true,
});
let coupons = await user.post('/coupons/generate/wondercon?count=2');
expect(coupons.length).to.equal(2);
expect(coupons[0].event).to.equal('wondercon');
expect(couponCode.validate(coupons[1]._id)).to.not.equal(''); // '' means invalid
});
});

View File

@@ -0,0 +1,36 @@
import {
generateUser,
requester,
resetHabiticaDB,
} from '../../../../helpers/api-v3-integration.helper';
describe('POST /coupons/validate/:code', () => {
let api = requester();
before(async () => {
await resetHabiticaDB();
});
it('returns an error if code is missing', async () => {
await expect(api.post(`/coupons/validate`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: 'Not found.',
});
});
it('returns true if coupon code is valid', async () => {
let sudoUser = await generateUser({
'contributor.sudo': true,
});
let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1');
let res = await api.post(`/coupons/validate/${coupon._id}`);
expect(res).to.eql({valid: true});
});
it('returns false if coupon code is valid', async () => {
let res = await api.post(`/coupons/validate/notValid`);
expect(res).to.eql({valid: false});
});
});

View File

@@ -0,0 +1,57 @@
/* eslint-disable global-require */
import {
generateRes,
generateReq,
generateNext,
} from '../../../../helpers/api-unit.helper';
import i18n from '../../../../../common/script/i18n';
import { ensureAdmin, ensureSudo } from '../../../../../website/src/middlewares/api-v3/ensureAccessRight';
import { NotAuthorized } from '../../../../../website/src/libs/api-v3/errors';
describe('ensure access middlewares', () => {
let res, req, next;
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
context('ensure admin', () => {
it('returns not authorized when user is not an admin', () => {
res.locals = {user: {contributor: {admin: false}}};
ensureAdmin(req, res, next);
expect(next).to.be.calledWith(new NotAuthorized(i18n.t('noAdminAccess')));
});
it('passes when user is an admin', () => {
res.locals = {user: {contributor: {admin: true}}};
ensureAdmin(req, res, next);
expect(next).to.be.calledOnce;
expect(next.args[0]).to.be.empty;
});
});
context('ensure sudo', () => {
it('returns not authorized when user is not a sudo user', () => {
res.locals = {user: {contributor: {sudo: false}}};
ensureSudo(req, res, next);
expect(next).to.be.calledWith(new NotAuthorized(i18n.t('noSudoAccess')));
});
it('passes when user is a sudo user', () => {
res.locals = {user: {contributor: {sudo: true}}};
ensureSudo(req, res, next);
expect(next).to.be.calledOnce;
expect(next.args[0]).to.be.empty;
});
});
});

View File

@@ -5,6 +5,7 @@ import { model as User } from '../../website/src/models/user';
import { model as Group } from '../../website/src/models/group'; import { model as Group } from '../../website/src/models/group';
import mongo from './mongo'; // eslint-disable-line import mongo from './mongo'; // eslint-disable-line
import moment from 'moment'; import moment from 'moment';
import i18n from '../../common/script/i18n';
afterEach((done) => { afterEach((done) => {
sandbox.restore(); sandbox.restore();
@@ -32,6 +33,9 @@ export function generateRes (options = {}) {
group: generateGroup(options.localsGroup), group: generateGroup(options.localsGroup),
}, },
set: sandbox.stub(), set: sandbox.stub(),
t (string) {
return i18n.t(string);
},
}; };
return defaults(options, defaultRes); return defaults(options, defaultRes);

View File

@@ -0,0 +1,125 @@
import csvStringify from '../../libs/api-v3/csvStringify';
import {
authWithHeaders,
authWithSession,
} from '../../middlewares/api-v3/auth';
import cron from '../../middlewares/api-v3/cron';
import { ensureSudo } from '../../middlewares/api-v3/ensureAccessRight';
import { model as Coupon } from '../../models/coupon';
import _ from 'lodash';
import couponCode from 'coupon-code';
let api = {};
/**
* @api {get} /coupons Get coupons (sudo users only)
* @apiVersion 3.0.0
* @apiName GetCoupons
* @apiGroup Coupon
*
* @apiSuccess string Coupons in CSV format
*/
api.getCoupons = {
method: 'GET',
url: '/coupons',
middlewares: [authWithSession, cron, ensureSudo],
async handler (req, res) {
let coupons = await Coupon.find().sort('createdAt').lean().exec();
let output = [['code', 'event', 'date', 'user']].concat(_.map(coupons, coupon => {
return [coupon._id, coupon.event, coupon.createdAt, coupon.user];
}));
let csv = await csvStringify(output);
res.set({
'Content-Type': 'text/csv',
'Content-disposition': `attachment; filename=habitica-coupons.csv`,
});
res.status(200).send(csv);
},
};
/**
* @api {post} /coupons/generate/:event Generate coupons for an event (sudo users only)
* @apiVersion 3.0.0
* @apiName GenerateCoupons
* @apiGroup Coupon
*
* @apiParam {string} event The event for which the coupon should be generated
* @apiParam {number} count Query parameter to specify the number of coupon codes to generate
*
* @apiSuccess array Generated coupons
*/
api.generateCoupons = {
method: 'POST',
url: '/coupons/generate/:event',
middlewares: [authWithHeaders(), cron, ensureSudo],
async handler (req, res) {
req.checkParams('event', res.t('eventRequired')).notEmpty();
req.checkQuery('count', res.t('countRequired')).notEmpty().isNumeric();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let coupons = await Coupon.generate(req.params.event, req.query.count);
res.respond(200, coupons);
},
};
/**
* @api {post} /user/coupon/:code Enter coupon code
* @apiVersion 3.0.0
* @apiName EnterCouponCode
* @apiGroup Coupon
*
* @apiParam {string} code The coupon code to apply
*
* @apiSuccess object User object
*/
api.enterCouponCode = {
method: 'POST',
url: '/coupons/enter/:code',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('code', res.t('couponCodeRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
await Coupon.apply(user, req, req.params.code);
res.respond(200, user);
},
};
/**
* @api {post} /coupons/validate/:code Validate a coupon code
* @apiVersion 3.0.0
* @apiName ValidateCoupon
* @apiGroup Coupon
*
* @apiSuccess valid {boolean} true or false
*/
api.validateCoupon = {
method: 'POST',
url: '/coupons/validate/:code',
middlewares: [authWithHeaders(true)],
async handler (req, res) {
req.checkParams('code', res.t('couponCodeRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let valid = false;
let code = couponCode.validate(req.params.code);
if (code) {
let coupon = await Coupon.findOne({_id: code}).exec();
valid = coupon ? true : false;
}
res.respond(200, {valid});
},
};
module.exports = api;

View File

@@ -1,9 +1,9 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth'; import { authWithHeaders } from '../../middlewares/api-v3/auth';
import { ensureAdmin } from '../../middlewares/api-v3/ensureAccessRight';
import cron from '../../middlewares/api-v3/cron'; import cron from '../../middlewares/api-v3/cron';
import { model as User } from '../../models/user'; import { model as User } from '../../models/user';
import { import {
NotFound, NotFound,
NotAuthorized,
} from '../../libs/api-v3/errors'; } from '../../libs/api-v3/errors';
import _ from 'lodash'; import _ from 'lodash';
@@ -90,9 +90,8 @@ const heroAdminFields = 'contributor balance profile.name purchased items auth';
api.getHero = { api.getHero = {
method: 'GET', method: 'GET',
url: '/hall/heroes/:heroId', url: '/hall/heroes/:heroId',
middlewares: [authWithHeaders(), cron], middlewares: [authWithHeaders(), cron, ensureAdmin],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user;
let heroId = req.params.heroId; let heroId = req.params.heroId;
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID(); req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID();
@@ -100,10 +99,6 @@ api.getHero = {
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
if (!user.contributor.admin) {
throw new NotAuthorized(res.t('noAdminAccess'));
}
let hero = await User let hero = await User
.findById(heroId) .findById(heroId)
.select(heroAdminFields) .select(heroAdminFields)
@@ -132,9 +127,8 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0};
api.updateHero = { api.updateHero = {
method: 'PUT', method: 'PUT',
url: '/hall/heroes/:heroId', url: '/hall/heroes/:heroId',
middlewares: [authWithHeaders(), cron], middlewares: [authWithHeaders(), cron, ensureAdmin],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user;
let heroId = req.params.heroId; let heroId = req.params.heroId;
let updateData = req.body; let updateData = req.body;
@@ -143,10 +137,6 @@ api.updateHero = {
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
if (!user.contributor.admin) {
throw new NotAuthorized(res.t('noAdminAccess'));
}
let hero = await User.findById(heroId).exec(); let hero = await User.findById(heroId).exec();
if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId}));

View File

@@ -0,0 +1,23 @@
import {
NotAuthorized,
} from '../../libs/api-v3/errors';
export function ensureAdmin (req, res, next) {
let user = res.locals.user;
if (!user.contributor.admin) {
return next(new NotAuthorized(res.t('noAdminAccess')));
}
next();
}
export function ensureSudo (req, res, next) {
let user = res.locals.user;
if (!user.contributor.sudo) {
return next(new NotAuthorized(res.t('noSudoAccess')));
}
next();
}

View File

@@ -1,59 +1,58 @@
var mongoose = require("mongoose"); /* eslint-disable camelcase */
var shared = require('../../../common');
var _ = require('lodash');
var async = require('async');
var cc = require('coupon-code');
var autoinc = require('mongoose-id-autoinc');
var CouponSchema = new mongoose.Schema({ import mongoose from 'mongoose';
_id: {type: String, 'default': cc.generate}, import _ from 'lodash';
event: {type:String, enum:['wondercon','google_6mo']}, import shared from '../../../common';
user: {type: 'String', ref: 'User'} import couponCode from 'coupon-code';
import baseModel from '../libs/api-v3/baseModel';
import {
BadRequest,
NotAuthorized,
} from '../libs/api-v3/errors';
export let schema = new mongoose.Schema({
event: {type: String, enum: ['wondercon', 'google_6mo']},
user: {type: String, ref: 'User'},
}); });
CouponSchema.statics.generate = function(event, count, callback) { schema.plugin(baseModel, {
async.times(count, function(n,cb){ timestamps: true,
mongoose.model('Coupon').create({event: event}, cb);
}, callback);
}
CouponSchema.statics.apply = function(user, code, next){
async.auto({
get_coupon: function (cb) {
mongoose.model('Coupon').findById(cc.validate(code), cb);
},
apply_coupon: ['get_coupon', function (cb, results) {
if (!results.get_coupon) return cb("Invalid coupon code");
if (results.get_coupon.user) return cb("Coupon already used");
switch (results.get_coupon.event) {
case 'wondercon':
user.items.gear.owned.eyewear_special_wondercon_red = true;
user.items.gear.owned.eyewear_special_wondercon_black = true;
user.items.gear.owned.back_special_wondercon_black = true;
user.items.gear.owned.back_special_wondercon_red = true;
user.items.gear.owned.body_special_wondercon_red = true;
user.items.gear.owned.body_special_wondercon_black = true;
user.items.gear.owned.body_special_wondercon_gold = true;
user.extra = {signupEvent: 'wondercon'};
user.save(cb);
break;
}
}],
expire_coupon: ['apply_coupon', function (cb, results) {
results.get_coupon.user = user._id;
results.get_coupon.save(cb);
}]
}, function(err, results){
if (err) return next(err);
next(null,results.apply_coupon[0]);
})
}
CouponSchema.plugin(autoinc.plugin, {
model: 'Coupon',
field: 'seq'
}); });
module.exports.schema = CouponSchema; // Add _id field after plugin to override default _id format
module.exports.model = mongoose.model("Coupon", CouponSchema); schema.add({
_id: {type: String, default: couponCode.generate},
});
schema.statics.generate = async function generateCoupons (event, count = 1) {
let coupons = _.times(count, () => {
return {event};
});
return await this.create(coupons);
};
schema.statics.apply = async function applyCoupon (user, req, code) {
let coupon = await this.findById(couponCode.validate(code)).exec();
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon', req.language));
if (coupon.user) throw new NotAuthorized(shared.i18n.t('couponUsed', req.language));
if (coupon.event === 'wondercon') {
user.items.gear.owned.eyewear_special_wondercon_red = true;
user.items.gear.owned.eyewear_special_wondercon_black = true;
user.items.gear.owned.back_special_wondercon_black = true;
user.items.gear.owned.back_special_wondercon_red = true;
user.items.gear.owned.body_special_wondercon_red = true;
user.items.gear.owned.body_special_wondercon_black = true;
user.items.gear.owned.body_special_wondercon_gold = true;
user.extra = {signupEvent: 'wondercon'};
}
await user.save();
coupon.user = user._id;
await coupon.save();
};
module.exports.schema = schema;
export let model = mongoose.model('Coupon', schema);