From a4be36d1590bcfbaa470a4c176cb06ecf389d1fb Mon Sep 17 00:00:00 2001 From: Jerko Steiner Date: Tue, 12 Mar 2019 11:46:41 +0500 Subject: [PATCH] Add tests for CommentRoutes --- .../server/src/comment/CommentRoutes.test.ts | 123 ++++++++++++++++-- packages/server/src/comment/CommentService.ts | 77 ++++++----- .../server/src/comment/CommentTestUtils.ts | 48 +++++++ packages/server/src/error/ErrorTransformer.ts | 25 ++++ packages/server/src/error/TransformedError.ts | 11 ++ 5 files changed, 240 insertions(+), 44 deletions(-) create mode 100644 packages/server/src/error/ErrorTransformer.ts create mode 100644 packages/server/src/error/TransformedError.ts diff --git a/packages/server/src/comment/CommentRoutes.test.ts b/packages/server/src/comment/CommentRoutes.test.ts index a98595f..7721241 100644 --- a/packages/server/src/comment/CommentRoutes.test.ts +++ b/packages/server/src/comment/CommentRoutes.test.ts @@ -1,5 +1,5 @@ import * as CommentTestUtils from './CommentTestUtils' -import {IComment, IStory} from '@rondo/common' +import {IStory} from '@rondo/common' import {createSite} from '../site/SiteTestUtils' import {getStory} from '../story/StoryTestUtils' import {test} from '../test' @@ -25,6 +25,13 @@ describe('comment', () => { story = await getStory(t, storyUrl) }) + async function createComment() { + return CommentTestUtils.createRootComment(t, { + storyId: story.id, + message: 'test', + }) + } + async function createChildComment() { const parent = await CommentTestUtils.createRootComment(t, { storyId: story.id, @@ -79,14 +86,8 @@ describe('comment', () => { describe('PUT /comments/:commentId', () => { - let comment: IComment - beforeEach(async () => { - comment = await CommentTestUtils.createRootComment(t, { - storyId: story.id, - message: 'test', - }) - }) it('updates a comment', async () => { + const comment = await createComment() await t.put('/comments/:commentId', { params: { commentId: comment.id, @@ -99,6 +100,7 @@ describe('comment', () => { const c = await CommentTestUtils.getCommentById(t, comment.id) expect(c.message).toEqual('test2') + // TODO save edit history }) @@ -108,23 +110,122 @@ describe('comment', () => { }) describe('DELETE /comments/:commentId', () => { - + it('soft deletes a comment', async () => { + const comment = await createComment() + await t.delete('/comments/:commentId', { + params: { + commentId: comment.id, + }, + }) + .expect(200) + const comment2 = await CommentTestUtils.getCommentById(t, comment.id) + expect(comment2.message) + .toEqual('(this message has been removed)') + }) }) describe('POST /comments/:commentId/vote', () => { + it('adds a new comment vote', async () => { + const comment = await createComment() + await CommentTestUtils.upVote(t, comment.id) + const c = await CommentTestUtils.getCommentById(t, comment.id) + expect(c.votes).toEqual(1) + }) + it('can only upvote once', async () => { + const comment = await createComment() + async function upVote() { + return t.post('/comments/:commentId/vote', { + params: { + commentId: comment.id, + }, + }) + } + const responses = (await Promise.all([ + upVote(), + upVote(), + ])).map(r => r.status) + + expect(responses).toContain(200) + expect(responses).toContain(400) + + const c = await CommentTestUtils.getCommentById(t, comment.id) + expect(c.votes).toEqual(1) + }) }) describe('DELETE /comments/:commentId/vote', () => { - + it('removes a comment vote', async () => { + let comment = await createComment() + await CommentTestUtils.upVote(t, comment.id) + comment = await CommentTestUtils.getCommentById(t, comment.id) + expect(comment.votes).toEqual(1) + await CommentTestUtils.downVote(t, comment.id) + comment = await CommentTestUtils.getCommentById(t, comment.id) + expect(comment.votes).toEqual(0) + }) + it('can only downvote once', async () => { + let comment = await createComment() + await CommentTestUtils.upVote(t, comment.id) + await Promise.all([ + CommentTestUtils.downVote(t, comment.id), + CommentTestUtils.downVote(t, comment.id), + ]) + comment = await CommentTestUtils.getCommentById(t, comment.id) + expect(comment.votes).toEqual(0) + }) }) describe('POST /comments/:commentId/spam', () => { + it('adds a new spam report', async () => { + const comment = await createComment() + await CommentTestUtils.markAsSpam(t, comment.id) + const c = await CommentTestUtils.getCommentById(t, comment.id) + expect(c.spams).toEqual(1) + }) + it('can only report a spam once', async () => { + const comment = await createComment() + async function markAsSpam() { + return t.post('/comments/:commentId/spam', { + params: { + commentId: comment.id, + }, + }) + } + const responses = (await Promise.all([ + markAsSpam(), + markAsSpam(), + ])).map(r => r.status) + + expect(responses).toContain(200) + expect(responses).toContain(400) + + const c = await CommentTestUtils.getCommentById(t, comment.id) + expect(c.spams).toEqual(1) + }) }) describe('DELETE /comments/:commentId/spam', () => { - + it('removes a spam report', async () => { + let comment = await createComment() + await CommentTestUtils.markAsSpam(t, comment.id) + comment = await CommentTestUtils.getCommentById(t, comment.id) + expect(comment.spams).toEqual(1) + await CommentTestUtils.unmarkAsSpam(t, comment.id) + comment = await CommentTestUtils.getCommentById(t, comment.id) + expect(comment.spams).toEqual(0) + }) + it('can only remove a spam report once', async () => { + let comment = await createComment() + await CommentTestUtils.markAsSpam(t, comment.id) + await Promise.all([ + CommentTestUtils.unmarkAsSpam(t, comment.id), + CommentTestUtils.unmarkAsSpam(t, comment.id), + ]) + comment = await CommentTestUtils.getCommentById(t, comment.id) + expect(comment.spams).toEqual(0) + }) }) }) diff --git a/packages/server/src/comment/CommentService.ts b/packages/server/src/comment/CommentService.ts index c25101c..628f870 100644 --- a/packages/server/src/comment/CommentService.ts +++ b/packages/server/src/comment/CommentService.ts @@ -1,8 +1,9 @@ +import {UniqueTransformer} from '../error/ErrorTransformer' import {BaseService} from '../services/BaseService' import {Comment} from '../entities/Comment' +import {ICommentService} from './ICommentService' import {ICommentTree} from '@rondo/common' import {IEditCommentParams} from './IEditCommentParams' -import {ICommentService} from './ICommentService' import {INewCommentParams} from './INewCommentParams' import {INewRootCommentParams} from './INewRootCommentParams' import {Spam} from '../entities/Spam' @@ -124,53 +125,62 @@ export class CommentService extends BaseService implements ICommentService { id: commentId, userId, }, { - message: '(this message has been removed by the user)', + message: '(this message has been removed)', }) return this.findOne(commentId) } async upVote(commentId: number, userId: number) { - await this.getRepository(Vote) - .save({ - commentId, - userId, - }) + try { + await this.getRepository(Vote) + .save({ + commentId, + userId, + }) + } catch (err) { + UniqueTransformer.transform(err, 'Already upvoted!') + } await this.getRepository(Comment) .createQueryBuilder() .update() .where({ id: commentId }) .set({ - score: () => 'score + 1', + votes: () => 'votes + 1', }) .execute() } async deleteVote(commentId: number, userId: number) { - const result = await this.getRepository(Vote) + await this.getRepository(Vote) .delete({ commentId, userId, }) - if (result.affected && result.affected === 1) { - await this.getRepository(Comment) - .createQueryBuilder() - .update() - .where({ id: commentId }) - .set({ - score: () => 'score - 1', - }) - } + // TODO rows.affected returns undefined or SQLite driver. This is an + // alternative query that does not depend on it. + await this.getRepository(Comment) + .createQueryBuilder() + .update() + .where({ id: commentId }) + .set({ + votes: () => '(select count(*) from vote where commentId = comment.id)', + }) + .execute() } async markAsSpam(commentId: number, userId: number) { - await this.getRepository(Spam) - .save({ - commentId, - userId, - }) + try { + await this.getRepository(Spam) + .save({ + commentId, + userId, + }) + } catch (err) { + UniqueTransformer.transform(err, 'Already marked as spam!') + } await this.getRepository(Comment) .createQueryBuilder() @@ -183,21 +193,22 @@ export class CommentService extends BaseService implements ICommentService { } async unmarkAsSpam(commentId: number, userId: number) { - const result = await this.getRepository(Spam) + await this.getRepository(Spam) .delete({ commentId, userId, }) - if (result.affected && result.affected === 1) { - await this.getRepository(Comment) - .createQueryBuilder() - .update() - .where({ id: commentId }) - .set({ - spams: () => 'spams - 1', - }) - } + // TODO rows.affected returns undefined or SQLite driver. This is an + // alternative query that does not depend on it. + await this.getRepository(Comment) + .createQueryBuilder() + .update() + .where({ id: commentId }) + .set({ + spams: () => '(select count(*) from spam where commentId = comment.id)', + }) + .execute() } } diff --git a/packages/server/src/comment/CommentTestUtils.ts b/packages/server/src/comment/CommentTestUtils.ts index 35ad9c3..7fe02b0 100644 --- a/packages/server/src/comment/CommentTestUtils.ts +++ b/packages/server/src/comment/CommentTestUtils.ts @@ -65,3 +65,51 @@ export async function getCommentById( return response.body! } + +export async function upVote( + t: RequestTester, + commentId: number, +) { + await t.post('/comments/:commentId/vote', { + params: { + commentId, + }, + }) + .expect(200) +} + +export async function downVote( + t: RequestTester, + commentId: number, +) { + await t.delete('/comments/:commentId/vote', { + params: { + commentId, + }, + }) + .expect(200) +} + +export async function markAsSpam( + t: RequestTester, + commentId: number, +) { + await t.post('/comments/:commentId/spam', { + params: { + commentId, + }, + }) + .expect(200) +} + +export async function unmarkAsSpam( + t: RequestTester, + commentId: number, +) { + await t.delete('/comments/:commentId/spam', { + params: { + commentId, + }, + }) + .expect(200) +} diff --git a/packages/server/src/error/ErrorTransformer.ts b/packages/server/src/error/ErrorTransformer.ts new file mode 100644 index 0000000..289867e --- /dev/null +++ b/packages/server/src/error/ErrorTransformer.ts @@ -0,0 +1,25 @@ +import {TransformedError} from './TransformedError' + +export class ErrorTransformer { + constructor( + readonly status: number, + readonly match: RegExp | string, + ) {} + + transform(err: Error, message: string) { + if (!this.isMatch(err)) { + throw err + } + throw new TransformedError(this.status, message, err) + } + + protected isMatch(err: Error): boolean { + const {match} = this + if (match instanceof RegExp) { + return match.test(err.message) + } + return match === err.message + } +} + +export const UniqueTransformer = new ErrorTransformer(400, /unique/i) diff --git a/packages/server/src/error/TransformedError.ts b/packages/server/src/error/TransformedError.ts new file mode 100644 index 0000000..ce45479 --- /dev/null +++ b/packages/server/src/error/TransformedError.ts @@ -0,0 +1,11 @@ +export class TransformedError extends Error { + constructor( + readonly status: number, + readonly message: string, + readonly cause: Error, + ) { + super(message) + Error.captureStackTrace(this) + this.stack! += '\n' + cause.stack + } +}