diff --git a/test/api/unit/middlewares/ensureAccessRight.test.js b/test/api/unit/middlewares/ensureAccessRight.test.js
index 30eb84b333..d163ae704d 100644
--- a/test/api/unit/middlewares/ensureAccessRight.test.js
+++ b/test/api/unit/middlewares/ensureAccessRight.test.js
@@ -5,7 +5,7 @@ import {
generateNext,
} from '../../../helpers/api-unit.helper';
import i18n from '../../../../website/common/script/i18n';
-import { ensureAdmin, ensureSudo } from '../../../../website/server/middlewares/ensureAccessRight';
+import { ensureAdmin, ensureSudo, ensureNewsPoster } from '../../../../website/server/middlewares/ensureAccessRight';
import { NotAuthorized } from '../../../../website/server/libs/errors';
import apiError from '../../../../website/server/libs/apiError';
@@ -40,6 +40,27 @@ describe('ensure access middlewares', () => {
});
});
+ context('ensure newsPoster', () => {
+ it('returns not authorized when user is not a newsPoster', () => {
+ res.locals = { user: { contributor: { newsPoster: false } } };
+
+ ensureNewsPoster(req, res, next);
+
+ const calledWith = next.getCall(0).args;
+ expect(calledWith[0].message).to.equal(apiError('noNewsPosterAccess'));
+ expect(calledWith[0] instanceof NotAuthorized).to.equal(true);
+ });
+
+ it('passes when user is a newsPoster', () => {
+ res.locals = { user: { contributor: { newsPoster: true } } };
+
+ ensureNewsPoster(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 } } };
diff --git a/test/api/unit/models/newsPost.js b/test/api/unit/models/newsPost.js
new file mode 100644
index 0000000000..883ffe193f
--- /dev/null
+++ b/test/api/unit/models/newsPost.js
@@ -0,0 +1,138 @@
+import { v4 } from 'uuid';
+import { model as NewsPost, refreshNewsPost } from '../../../../website/server/models/newsPost';
+import { sleep } from '../../../helpers/api-unit.helper';
+
+describe('NewsPost Model', () => {
+ const publishDate = Number(new Date());
+
+ // NOTE publishDate is manually increased by +500 for each test
+ // to make sure it's always in the future from the previous one
+ // bevause NewsPost.lastNewsPost() is not reset between tests.
+ // And without a more recent publishDate it wouldn't update
+
+ it('#lastNewsPost', () => {
+ const lastPost = { _id: v4(), publishDate, published: true };
+ NewsPost.updateLastNewsPost(lastPost);
+ expect(NewsPost.lastNewsPost()).to.equal(lastPost);
+ });
+
+ it('#getLastPostFromDatabase', async () => {
+ const expectedId = v4();
+
+ await NewsPost.create([
+ // more recent but not published
+ {
+ _id: v4(),
+ publishDate: new Date(publishDate + 50),
+ author: v4(),
+ published: false,
+ title: 'Title',
+ credits: 'credits',
+ text: 'text',
+ },
+ // expected
+ {
+ _id: expectedId,
+ publishDate,
+ author: v4(),
+ published: true,
+ title: 'Title',
+ credits: 'credits',
+ text: 'text',
+ },
+ // published but less recent
+ {
+ _id: v4(),
+ publishDate: new Date(Number(publishDate) - 50),
+ author: v4(),
+ published: true,
+ title: 'Title',
+ credits: 'credits',
+ text: 'text',
+ },
+ ]);
+
+ const fetched = await NewsPost.getLastPostFromDatabase();
+ expect(fetched._id).to.equal(expectedId);
+ });
+
+ context('#updateLastNewsPost', () => {
+ it('updates the post if new one is more recent and published', () => {
+ const previousPost = {
+ _id: v4(),
+ publishDate: new Date(publishDate + 100),
+ published: true,
+ };
+ NewsPost.updateLastNewsPost(previousPost);
+ const newPost = {
+ _id: v4(),
+ publishDate: new Date(publishDate + 150),
+ published: true,
+ };
+ NewsPost.updateLastNewsPost(newPost);
+ expect(NewsPost.lastNewsPost()._id).to.equal(newPost._id);
+ });
+
+ it('does not update the post if new one is from the past', () => {
+ const previousPost = new NewsPost({
+ _id: v4(), publishDate: new Date(publishDate + 200), published: true,
+ });
+ NewsPost.updateLastNewsPost(previousPost);
+ const newPost = new NewsPost({
+ _id: v4(), publishDate: new Date(publishDate + 175), published: true,
+ });
+ NewsPost.updateLastNewsPost(newPost);
+ expect(NewsPost.lastNewsPost()._id).to.equal(previousPost._id);
+ });
+
+ it('does not update the post if new one is not published', () => {
+ const previousPost = new NewsPost({
+ _id: v4(), publishDate: new Date(publishDate + 250), published: true,
+ });
+ NewsPost.updateLastNewsPost(previousPost);
+ const newPost = new NewsPost({
+ _id: v4(), publishDate: new Date(publishDate + 300), published: false,
+ });
+ NewsPost.updateLastNewsPost(newPost);
+ expect(NewsPost.lastNewsPost()._id).to.equal(previousPost._id);
+ });
+ });
+
+ context('refreshes NewsPost', () => {
+ let intervalId;
+
+ beforeEach(async () => {
+ // Delete all existing posts from the database
+ await NewsPost.remove();
+ });
+
+ afterEach(() => {
+ if (intervalId) clearInterval(intervalId);
+ });
+
+ it('refreshes the last post at a specific interval', async () => {
+ await sleep(0.1); // wait 100ms to make sure all previous posts are in the past
+ const previousPost = {
+ _id: v4(), publishDate: new Date(), published: true,
+ };
+ NewsPost.updateLastNewsPost(previousPost);
+ intervalId = refreshNewsPost(50); // refreshes every 50ms
+
+ await sleep(0.1); // wait 100ms to make sure the new post has a more recent publishDate
+ const newPost = await NewsPost.create({
+ _id: v4(),
+ publishDate: new Date(),
+ author: v4(),
+ published: true,
+ title: 'Title',
+ credits: 'credits',
+ text: 'text',
+ });
+
+ expect(NewsPost.lastNewsPost()._id).to.equal(previousPost._id);
+ await sleep(0.15); // wait 150ms
+
+ expect(NewsPost.lastNewsPost()._id).to.equal(newPost._id);
+ });
+ });
+});
diff --git a/test/api/unit/models/user.test.js b/test/api/unit/models/user.test.js
index f3345e5478..b3a5200ece 100644
--- a/test/api/unit/models/user.test.js
+++ b/test/api/unit/models/user.test.js
@@ -1,87 +1,90 @@
import moment from 'moment';
import { model as User } from '../../../../website/server/models/user';
+import { model as NewsPost } from '../../../../website/server/models/newsPost';
import { model as Group } from '../../../../website/server/models/group';
import common from '../../../../website/common';
describe('User Model', () => {
- it('keeps user._tmp when calling .toJSON', () => {
- const user = new User({
- auth: {
- local: {
- username: 'username',
- lowerCaseUsername: 'username',
- email: 'email@email.email',
- salt: 'salt',
- hashed_password: 'hashed_password', // eslint-disable-line camelcase
+ describe('.toJSON()', () => {
+ it('keeps user._tmp when calling .toJSON', () => {
+ const user = new User({
+ auth: {
+ local: {
+ username: 'username',
+ lowerCaseUsername: 'username',
+ email: 'email@email.email',
+ salt: 'salt',
+ hashed_password: 'hashed_password', // eslint-disable-line camelcase
+ },
},
- },
+ });
+
+ user._tmp = { ok: true };
+ user._nonTmp = { ok: true };
+
+ expect(user._tmp).to.eql({ ok: true });
+ expect(user._nonTmp).to.eql({ ok: true });
+
+ const toObject = user.toObject();
+ const toJSON = user.toJSON();
+
+ expect(toObject).to.not.have.keys('_tmp');
+ expect(toObject).to.not.have.keys('_nonTmp');
+
+ expect(toJSON).to.have.any.key('_tmp');
+ expect(toJSON._tmp).to.eql({ ok: true });
+ expect(toJSON).to.not.have.keys('_nonTmp');
});
- user._tmp = { ok: true };
- user._nonTmp = { ok: true };
+ it('can add computed stats to a JSONified user object', () => {
+ const user = new User();
+ const userToJSON = user.toJSON();
- expect(user._tmp).to.eql({ ok: true });
- expect(user._nonTmp).to.eql({ ok: true });
+ expect(userToJSON.stats.maxMP).to.not.exist;
+ expect(userToJSON.stats.maxHealth).to.not.exist;
+ expect(userToJSON.stats.toNextLevel).to.not.exist;
- const toObject = user.toObject();
- const toJSON = user.toJSON();
+ User.addComputedStatsToJSONObj(userToJSON.stats, userToJSON);
- expect(toObject).to.not.have.keys('_tmp');
- expect(toObject).to.not.have.keys('_nonTmp');
+ expect(userToJSON.stats.maxMP).to.exist;
+ expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth);
+ expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl));
+ });
- expect(toJSON).to.have.any.key('_tmp');
- expect(toJSON._tmp).to.eql({ ok: true });
- expect(toJSON).to.not.have.keys('_nonTmp');
- });
+ it('can transform user object without mongoose helpers', async () => {
+ const user = new User();
+ await user.save();
+ const userToJSON = await User.findById(user._id).lean().exec();
- it('can add computed stats to a JSONified user object', () => {
- const user = new User();
- const userToJSON = user.toJSON();
+ expect(userToJSON.stats.maxMP).to.not.exist;
+ expect(userToJSON.stats.maxHealth).to.not.exist;
+ expect(userToJSON.stats.toNextLevel).to.not.exist;
+ expect(userToJSON.id).to.not.exist;
- expect(userToJSON.stats.maxMP).to.not.exist;
- expect(userToJSON.stats.maxHealth).to.not.exist;
- expect(userToJSON.stats.toNextLevel).to.not.exist;
+ User.transformJSONUser(userToJSON);
- User.addComputedStatsToJSONObj(userToJSON.stats, userToJSON);
+ expect(userToJSON.id).to.equal(userToJSON._id);
+ expect(userToJSON.stats.maxMP).to.not.exist;
+ expect(userToJSON.stats.maxHealth).to.not.exist;
+ expect(userToJSON.stats.toNextLevel).to.not.exist;
+ });
- expect(userToJSON.stats.maxMP).to.exist;
- expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth);
- expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl));
- });
+ it('can transform user object without mongoose helpers (including computed stats)', async () => {
+ const user = new User();
+ await user.save();
+ const userToJSON = await User.findById(user._id).lean().exec();
- it('can transform user object without mongoose helpers', async () => {
- const user = new User();
- await user.save();
- const userToJSON = await User.findById(user._id).lean().exec();
+ expect(userToJSON.stats.maxMP).to.not.exist;
+ expect(userToJSON.stats.maxHealth).to.not.exist;
+ expect(userToJSON.stats.toNextLevel).to.not.exist;
- expect(userToJSON.stats.maxMP).to.not.exist;
- expect(userToJSON.stats.maxHealth).to.not.exist;
- expect(userToJSON.stats.toNextLevel).to.not.exist;
- expect(userToJSON.id).to.not.exist;
+ User.transformJSONUser(userToJSON, true);
- User.transformJSONUser(userToJSON);
-
- expect(userToJSON.id).to.equal(userToJSON._id);
- expect(userToJSON.stats.maxMP).to.not.exist;
- expect(userToJSON.stats.maxHealth).to.not.exist;
- expect(userToJSON.stats.toNextLevel).to.not.exist;
- });
-
- it('can transform user object without mongoose helpers (including computed stats)', async () => {
- const user = new User();
- await user.save();
- const userToJSON = await User.findById(user._id).lean().exec();
-
- expect(userToJSON.stats.maxMP).to.not.exist;
- expect(userToJSON.stats.maxHealth).to.not.exist;
- expect(userToJSON.stats.toNextLevel).to.not.exist;
-
- User.transformJSONUser(userToJSON, true);
-
- expect(userToJSON.id).to.equal(userToJSON._id);
- expect(userToJSON.stats.maxMP).to.exist;
- expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth);
- expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl));
+ expect(userToJSON.id).to.equal(userToJSON._id);
+ expect(userToJSON.stats.maxMP).to.exist;
+ expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth);
+ expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl));
+ });
});
context('achievements', () => {
@@ -827,4 +830,46 @@ describe('User Model', () => {
expect(daysMissed).to.eql(0);
});
});
+
+ it('isNewsPoster', async () => {
+ const user = new User();
+ await user.save();
+
+ expect(user.isNewsPoster()).to.equal(false);
+
+ user.contributor.newsPoster = true;
+ expect(user.isNewsPoster()).to.equal(true);
+ });
+
+ describe('checkNewStuff', () => {
+ let user;
+
+ beforeEach(() => {
+ user = new User();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it('no last news post', () => {
+ sandbox.stub(NewsPost, 'lastNewsPost').returns(null);
+ expect(user.checkNewStuff()).to.equal(false);
+ expect(user.toJSON().flags.newStuff).to.equal(false);
+ });
+
+ it('last news post read', () => {
+ sandbox.stub(NewsPost, 'lastNewsPost').returns({ _id: '123' });
+ user.flags.lastNewStuffRead = '123';
+ expect(user.checkNewStuff()).to.equal(false);
+ expect(user.toJSON().flags.newStuff).to.equal(false);
+ });
+
+ it('last news post not read', () => {
+ sandbox.stub(NewsPost, 'lastNewsPost').returns({ _id: '123' });
+ user.flags.lastNewStuffRead = '124';
+ expect(user.checkNewStuff()).to.equal(true);
+ expect(user.toJSON().flags.newStuff).to.equal(true);
+ });
+ });
});
diff --git a/test/api/v3/integration/news/GET-news.test.js b/test/api/v3/integration/news/GET-news.test.js
index 8de8afbc05..b47e433a6a 100644
--- a/test/api/v3/integration/news/GET-news.test.js
+++ b/test/api/v3/integration/news/GET-news.test.js
@@ -4,7 +4,6 @@ import {
describe('GET /news', () => {
let api;
-
beforeEach(async () => {
api = requester();
});
diff --git a/test/api/v3/integration/news/POST-news_tell_me_later.test.js b/test/api/v3/integration/news/POST-news_tell_me_later.test.js
index bd1cdfb147..c5c91f642c 100644
--- a/test/api/v3/integration/news/POST-news_tell_me_later.test.js
+++ b/test/api/v3/integration/news/POST-news_tell_me_later.test.js
@@ -1,24 +1,27 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
+import { model as NewsPost } from '../../../../../website/server/models/newsPost';
describe('POST /news/tell-me-later', () => {
let user;
beforeEach(async () => {
- user = await generateUser({
- 'flags.newStuff': true,
+ NewsPost.updateLastNewsPost({
+ _id: '1234', publishDate: new Date(), title: 'Title', published: true,
});
+ user = await generateUser();
});
it('marks new stuff as read and adds notification', async () => {
- expect(user.flags.newStuff).to.equal(true);
const initialNotifications = user.notifications.length;
await user.post('/news/tell-me-later');
await user.sync();
- expect(user.flags.newStuff).to.equal(false);
+ expect(user.flags.lastNewStuffRead).to.equal('1234');
+ // fetching the user because newStuff is a computed property
+ expect((await user.get('/user')).flags.newStuff).to.equal(false);
expect(user.notifications.length).to.equal(initialNotifications + 1);
const notification = user.notifications[user.notifications.length - 1];
diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js
index 71e8500263..9025afb326 100644
--- a/test/api/v3/integration/user/PUT-user.test.js
+++ b/test/api/v3/integration/user/PUT-user.test.js
@@ -3,6 +3,7 @@ import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
+import { model as NewsPost } from '../../../../../website/server/models/newsPost';
describe('PUT /user', () => {
let user;
@@ -101,6 +102,24 @@ describe('PUT /user', () => {
message: t('displaynameIssueNewline'),
});
});
+
+ it('can set flags.newStuff to false', async () => {
+ NewsPost.updateLastNewsPost({
+ _id: '1234', publishDate: new Date(), title: 'Title', published: true,
+ });
+
+ await user.update({
+ 'flags.lastNewStuffRead': '123',
+ });
+
+ await user.put('/user', {
+ 'flags.newStuff': false,
+ });
+
+ await user.sync();
+
+ expect(user.flags.lastNewStuffRead).to.eql('1234');
+ });
});
context('Top Level Protected Operations', () => {
diff --git a/test/api/v4/news/DELETE-news.test.js b/test/api/v4/news/DELETE-news.test.js
new file mode 100644
index 0000000000..211c0d706d
--- /dev/null
+++ b/test/api/v4/news/DELETE-news.test.js
@@ -0,0 +1,49 @@
+import { v4 } from 'uuid';
+import {
+ generateUser,
+ translate as t,
+} from '../../../helpers/api-integration/v4';
+
+describe('DELETE /news/:newsID', () => {
+ let user;
+ const newsPost = {
+ title: 'New Post',
+ publishDate: new Date(),
+ published: true,
+ credits: 'credits',
+ text: 'news body',
+ };
+ beforeEach(async () => {
+ user = await generateUser({
+ 'contributor.newsPoster': true,
+ });
+ });
+
+ it('disallows access to non-newsPosters', async () => {
+ const nonAdminUser = await generateUser({ 'contributor.newsPoster': false });
+ await expect(nonAdminUser.del(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: 'You don\'t have news poster access.',
+ });
+ });
+
+ it('returns an error if the post does not exist', async () => {
+ await expect(user.del(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('newsPostNotFound'),
+ });
+ });
+
+ it('deletes news posts', async () => {
+ const existingPost = await user.post('/news', newsPost);
+ await user.del(`/news/${existingPost._id}`);
+
+ const returnedPosts = await user.get('/news');
+ const deletedPost = returnedPosts.find(returnedPost => returnedPost._id === existingPost._id);
+
+ expect(returnedPosts).is.an('array');
+ expect(deletedPost).to.not.exist;
+ });
+});
diff --git a/test/api/v4/news/GET-news.test.js b/test/api/v4/news/GET-news.test.js
new file mode 100644
index 0000000000..64db6d8391
--- /dev/null
+++ b/test/api/v4/news/GET-news.test.js
@@ -0,0 +1,50 @@
+import {
+ requester, generateUser,
+} from '../../../helpers/api-integration/v4';
+
+describe('GET /news', () => {
+ let api;
+ const newsPost = {
+ title: 'New Post',
+ publishDate: new Date(),
+ published: true,
+ credits: 'credits',
+ text: 'news body',
+ };
+
+ before(async () => {
+ api = requester();
+ const user = await generateUser({
+ 'contributor.newsPoster': true,
+ });
+
+ await Promise.all([
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ user.post('/news', newsPost),
+ ]);
+ });
+
+ it('returns the latest news in json format, does not require authentication, 10 per page', async () => {
+ const res = await api.get('/news');
+ expect(res.length).to.be.equal(10);
+ expect(res[0].title).to.be.not.empty;
+ expect(res[0].text).to.be.not.empty;
+ });
+
+ it('supports pagination', async () => {
+ const res = await api.get('/news?page=1');
+ expect(res.length).to.be.equal(2);
+ expect(res[0].title).to.be.not.empty;
+ expect(res[0].text).to.be.not.empty;
+ });
+});
diff --git a/test/api/v4/news/GET-news_id.test.js b/test/api/v4/news/GET-news_id.test.js
new file mode 100644
index 0000000000..a6b7ed0579
--- /dev/null
+++ b/test/api/v4/news/GET-news_id.test.js
@@ -0,0 +1,36 @@
+import { v4 } from 'uuid';
+import {
+ generateUser,
+ translate as t,
+} from '../../../helpers/api-integration/v4';
+
+describe('GET /news/:newsID', () => {
+ let user;
+ const newsPost = {
+ title: 'New Post',
+ publishDate: new Date(),
+ published: true,
+ credits: 'credits',
+ text: 'news body',
+ };
+ beforeEach(async () => {
+ user = await generateUser({
+ 'contributor.newsPoster': true,
+ });
+ });
+
+ it('returns an error if the post does not exist', async () => {
+ await expect(user.get(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('newsPostNotFound'),
+ });
+ });
+
+ it('fetches an existing post', async () => {
+ const existingPost = await user.post('/news', newsPost);
+ const fetchedPost = await user.get(`/news/${existingPost._id}`);
+
+ expect(fetchedPost._id).to.equal(existingPost._id);
+ });
+});
diff --git a/test/api/v4/news/POST-news.test.js b/test/api/v4/news/POST-news.test.js
new file mode 100644
index 0000000000..f102616624
--- /dev/null
+++ b/test/api/v4/news/POST-news.test.js
@@ -0,0 +1,134 @@
+import moment from 'moment';
+import {
+ generateUser,
+ sleep,
+} from '../../../helpers/api-integration/v4';
+import { model as NewsPost } from '../../../../website/server/models/newsPost';
+
+describe('POST /news', () => {
+ let user;
+ const newsPost = {
+ title: 'New Post',
+ publishDate: new Date(),
+ published: true,
+ credits: 'credits',
+ text: 'news body',
+ };
+ beforeEach(async () => {
+ user = await generateUser({
+ 'contributor.newsPoster': true,
+ });
+ });
+
+ it('disallows access to non-admins', async () => {
+ const nonAdminUser = await generateUser({ 'contributor.newsPoster': false });
+ await expect(nonAdminUser.post('/news')).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: 'You don\'t have news poster access.',
+ });
+ });
+
+ it('creates news posts', async () => {
+ const response = await user.post('/news', newsPost);
+
+ expect(response.title).to.equal(newsPost.title);
+ expect(response.credits).to.equal(newsPost.credits);
+ expect(response.text).to.equal(newsPost.text);
+ expect(response._id).to.exist;
+
+ const res = await user.get('/news');
+ expect(res[0]._id).to.equal(response._id);
+ expect(res[0].title).to.equal(newsPost.title);
+ expect(res[0].text).to.equal(newsPost.text);
+ });
+
+ context('calls updateLastNewsPost', () => {
+ beforeEach(async () => {
+ await NewsPost.remove({ });
+ });
+
+ afterEach(async () => {
+ newsPost.publishDate = new Date();
+ newsPost.published = true;
+ });
+
+ it('new post is published and the most recent one', async () => {
+ newsPost.publishDate = new Date();
+ const newPost = await user.post('/news', newsPost);
+ await sleep(0.05);
+ expect(NewsPost.lastNewsPost()._id).to.equal(newPost._id);
+ });
+
+ it('new post is not published', async () => {
+ newsPost.published = false;
+ const newPost = await user.post('/news', newsPost);
+ await sleep(0.05);
+ expect(NewsPost.lastNewsPost()._id).to.not.equal(newPost._id);
+ });
+
+ it('new post is published but in the future', async () => {
+ newsPost.publishDate = moment().add({ days: 1 }).toDate();
+ const newPost = await user.post('/news', newsPost);
+ await sleep(0.05);
+ expect(NewsPost.lastNewsPost()._id).to.not.equal(newPost._id);
+ });
+
+ it('new post is published but not the most recent one', async () => {
+ const oldPost = await user.post('/news', newsPost);
+ newsPost.publishDate = moment().subtract({ days: 1 }).toDate();
+ await user.post('/news', newsPost);
+ await sleep(0.05);
+ expect(NewsPost.lastNewsPost()._id).to.equal(oldPost._id);
+ });
+ });
+
+ it('sets default fields', async () => {
+ const response = await user.post('/news', {
+ title: 'A post',
+ credits: 'Credits',
+ text: 'Text',
+ });
+
+ expect(response.published).to.equal(false);
+ expect(response.publishDate).to.exist;
+ expect(response.author).to.equal(user._id);
+ expect(response.createdAt).to.exist;
+ expect(response.updatedAt).to.exist;
+ });
+
+ context('required fields', () => {
+ it('title', async () => {
+ await expect(user.post('/news', {
+ text: 'Text',
+ credits: 'Credits',
+ })).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: 'NewsPost validation failed',
+ });
+ });
+
+ it('credits', async () => {
+ await expect(user.post('/news', {
+ text: 'Text',
+ title: 'Title',
+ })).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: 'NewsPost validation failed',
+ });
+ });
+
+ it('text', async () => {
+ await expect(user.post('/news', {
+ credits: 'credits',
+ title: 'Title',
+ })).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: 'NewsPost validation failed',
+ });
+ });
+ });
+});
diff --git a/test/api/v4/news/POST-news_read.test.js b/test/api/v4/news/POST-news_read.test.js
new file mode 100644
index 0000000000..f82c2a7fb1
--- /dev/null
+++ b/test/api/v4/news/POST-news_read.test.js
@@ -0,0 +1,22 @@
+import {
+ generateUser,
+} from '../../../helpers/api-integration/v4';
+import { model as NewsPost } from '../../../../website/server/models/newsPost';
+
+describe('POST /news/read', () => {
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+ });
+
+ it('marks new stuff as read', async () => {
+ NewsPost.updateLastNewsPost({ _id: '1234', publishDate: new Date(), published: true });
+ await user.post('/news/read');
+ await user.sync();
+
+ expect(user.flags.lastNewStuffRead).to.equal('1234');
+ // fetching the user because newStuff is a computed property
+ expect((await user.get('/user')).flags.newStuff).to.equal(false);
+ });
+});
diff --git a/test/api/v4/news/PUT-news_newsId.test.js b/test/api/v4/news/PUT-news_newsId.test.js
new file mode 100644
index 0000000000..6301b94aa3
--- /dev/null
+++ b/test/api/v4/news/PUT-news_newsId.test.js
@@ -0,0 +1,103 @@
+import { v4 } from 'uuid';
+import {
+ generateUser,
+ translate as t,
+ sleep,
+} from '../../../helpers/api-integration/v4';
+import { model as NewsPost } from '../../../../website/server/models/newsPost';
+
+describe('PUT /news/:newsID', () => {
+ let user;
+ const newsPost = {
+ title: 'New Post',
+ publishDate: new Date(),
+ published: true,
+ credits: 'credits',
+ text: 'news body',
+ };
+ beforeEach(async () => {
+ user = await generateUser({
+ 'contributor.newsPoster': true,
+ });
+ });
+
+ it('disallows access to non-admins', async () => {
+ const nonAdminUser = await generateUser({ 'contributor.newsPoster': false });
+ await expect(nonAdminUser.put('/news/1234')).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: 'You don\'t have news poster access.',
+ });
+ });
+
+ it('returns an error if the post does not exist', async () => {
+ await expect(user.put(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('newsPostNotFound'),
+ });
+ });
+
+ it('updates existing news posts', async () => {
+ const existingPost = await user.post('/news', newsPost);
+ const updatedPost = await user.put(`/news/${existingPost._id}`, {
+ title: 'Changed Title',
+ });
+
+ expect(updatedPost.title).to.equal('Changed Title');
+ expect(updatedPost.credits).to.equal(existingPost.credits);
+ expect(updatedPost.text).to.equal(existingPost.text);
+ expect(updatedPost.published).to.equal(existingPost.published);
+ expect(updatedPost._id).to.equal(existingPost._id);
+ });
+
+ context('calls updateLastNewsPost', () => {
+ beforeEach(async () => {
+ await NewsPost.remove({ });
+ });
+
+ it('updates post data', async () => {
+ const existingPost = await user.post('/news', { ...newsPost, publishDate: new Date() });
+ const updatedPost = await user.put(`/news/${existingPost._id}`, {
+ title: 'Changed Title',
+ });
+ await sleep(0.05);
+
+ expect(NewsPost.lastNewsPost().title).to.equal(updatedPost.title);
+ });
+
+ it('updated post is not published', async () => {
+ const oldPost = await user.post('/news', { ...newsPost, publishDate: new Date() });
+ const newUnpublished = await user.post('/news', { ...newsPost, published: false });
+ await user.put(`/news/${newUnpublished._id}`, {
+ title: 'Changed Title',
+ });
+ await sleep(0.05);
+
+ expect(NewsPost.lastNewsPost()._id).to.equal(oldPost._id);
+ });
+
+ it('updated post is published', async () => {
+ await user.post('/news', { ...newsPost, publishDate: new Date() });
+ const newUnpublished = await user.post('/news', { ...newsPost, published: false, publishDate: new Date() });
+ await user.put(`/news/${newUnpublished._id}`, {
+ publishDate: new Date(),
+ published: true,
+ });
+ await sleep(0.05);
+
+ expect(NewsPost.lastNewsPost()._id).to.equal(newUnpublished._id);
+ });
+
+ it('updated post publishDate is in future', async () => {
+ const oldPost = await user.post('/news', { ...newsPost, publishDate: new Date() });
+ const newUnpublished = await user.post('/news', newsPost);
+ await user.put(`/news/${newUnpublished._id}`, {
+ publishDate: Date.now() + 50000,
+ });
+ await sleep(0.05);
+
+ expect(NewsPost.lastNewsPost()._id).to.equal(oldPost._id);
+ });
+ });
+});
diff --git a/website/client/src/assets/scss/modal.scss b/website/client/src/assets/scss/modal.scss
index baec4afd79..15fae725c4 100644
--- a/website/client/src/assets/scss/modal.scss
+++ b/website/client/src/assets/scss/modal.scss
@@ -5,6 +5,10 @@
padding-left: 0px !important;
}
+.modal-content {
+ border-radius: 8px;
+}
+
.modal-dialog {
margin: 3rem auto 3rem;
width: auto;
diff --git a/website/client/src/components/achievements/newStuff.vue b/website/client/src/components/achievements/newStuff.vue
deleted file mode 100644
index 53c427a383..0000000000
--- a/website/client/src/components/achievements/newStuff.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-
-
+ {{ $t('newStuff') }}
+
+
+ {{ getPostDate(post) }} - {{ post.title.toUpperCase() }}
+
+
+
+
+
+ by {{ post.credits }}
+
+ ${md.unsafeHTMLRender(lastNewsPost.text)} +
+- We’ve added three new backgrounds to the Background Shop! Now your avatar can dare to - visit a Haunted Forest, brave the Spooky Scarecrow Field, or bask in the glow of the - Crescent Moon. Check them out under User Icon > Backgrounds on web and Menu > Inventory > - Customize Avatar on mobile! -
-- Plus, there’s new Gold-purchasable equipment in the Enchanted Armoire, including the - Autumn Enchanter Set. Better work hard on your real-life tasks to earn all the pieces! - Enjoy :) -
-- There's a new Gold-purchasable item in the Seasonal Shop: - Spooky Sparkles! Buy some and then cast it on your friends. I wonder what it will do? -
-- If you have Spooky Sparkles cast on you, you will receive the "Alarming Friends" badge! - Don't worry, any mysterious effects will wear off the next day.... or you can cancel them - early by buying an Opaque Potion! -
-- While you're at it, be sure to check out all the other items in the Seasonal Shop! There - are lots of equipment items from the previous Fall Festivals. The Seasonal Shop will only - be open until October 31st, so stock up now. -
-