initial commit
Change-Id: Ifa2442bb2e786af351ef5f575ca69828870c8b6e
This commit is contained in:
12
src/app/models/http-exception.model.ts
Normal file
12
src/app/models/http-exception.model.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
class HttpException extends Error {
|
||||
errorCode: number;
|
||||
constructor(
|
||||
errorCode: number,
|
||||
public readonly message: string | any,
|
||||
) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
export default HttpException;
|
||||
243
src/app/routes/article/article.controller.ts
Normal file
243
src/app/routes/article/article.controller.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import auth from '../auth/auth';
|
||||
import {
|
||||
addComment,
|
||||
createArticle,
|
||||
deleteArticle,
|
||||
deleteComment,
|
||||
favoriteArticle,
|
||||
getArticle,
|
||||
getArticles,
|
||||
getCommentsByArticle,
|
||||
getFeed,
|
||||
unfavoriteArticle,
|
||||
updateArticle,
|
||||
} from './article.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Get paginated articles
|
||||
* @auth optional
|
||||
* @route {GET} /articles
|
||||
* @queryparam offset number of articles dismissed from the first one
|
||||
* @queryparam limit number of articles returned
|
||||
* @queryparam tag
|
||||
* @queryparam author
|
||||
* @queryparam favorited
|
||||
* @returns articles: list of articles
|
||||
*/
|
||||
router.get('/articles', auth.optional, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await getArticles(req.query, req.auth?.user?.id);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get paginated feed articles
|
||||
* @auth required
|
||||
* @route {GET} /articles/feed
|
||||
* @returns articles list of articles
|
||||
*/
|
||||
router.get(
|
||||
'/articles/feed',
|
||||
auth.required,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await getFeed(
|
||||
Number(req.query.offset),
|
||||
Number(req.query.limit),
|
||||
req.auth?.user?.id,
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create article
|
||||
* @route {POST} /articles
|
||||
* @bodyparam title
|
||||
* @bodyparam description
|
||||
* @bodyparam body
|
||||
* @bodyparam tagList list of tags
|
||||
* @returns article created article
|
||||
*/
|
||||
router.post('/articles', auth.required, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const article = await createArticle(req.body.article, req.auth?.user?.id);
|
||||
res.status(201).json({ article });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get unique article
|
||||
* @auth optional
|
||||
* @route {GET} /article/:slug
|
||||
* @param slug slug of the article (based on the title)
|
||||
* @returns article
|
||||
*/
|
||||
router.get(
|
||||
'/articles/:slug',
|
||||
auth.optional,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const article = await getArticle(req.params.slug, req.auth?.user?.id);
|
||||
res.json({ article });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Update article
|
||||
* @auth required
|
||||
* @route {PUT} /articles/:slug
|
||||
* @param slug slug of the article (based on the title)
|
||||
* @bodyparam title new title
|
||||
* @bodyparam description new description
|
||||
* @bodyparam body new content
|
||||
* @returns article updated article
|
||||
*/
|
||||
router.put(
|
||||
'/articles/:slug',
|
||||
auth.required,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const article = await updateArticle(req.body.article, req.params.slug, req.auth?.user?.id);
|
||||
res.json({ article });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete article
|
||||
* @auth required
|
||||
* @route {DELETE} /article/:id
|
||||
* @param slug slug of the article
|
||||
*/
|
||||
router.delete(
|
||||
'/articles/:slug',
|
||||
auth.required,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await deleteArticle(req.params.slug, req.auth?.user!.id);
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get comments from an article
|
||||
* @auth optional
|
||||
* @route {GET} /articles/:slug/comments
|
||||
* @param slug slug of the article (based on the title)
|
||||
* @returns comments list of comments
|
||||
*/
|
||||
router.get(
|
||||
'/articles/:slug/comments',
|
||||
auth.optional,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const comments = await getCommentsByArticle(req.params.slug, req.auth?.user?.id);
|
||||
res.json({ comments });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Add comment to article
|
||||
* @auth required
|
||||
* @route {POST} /articles/:slug/comments
|
||||
* @param slug slug of the article (based on the title)
|
||||
* @bodyparam body content of the comment
|
||||
* @returns comment created comment
|
||||
*/
|
||||
router.post(
|
||||
'/articles/:slug/comments',
|
||||
auth.required,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const comment = await addComment(req.body.comment.body, req.params.slug, req.auth?.user?.id);
|
||||
res.json({ comment });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete comment
|
||||
* @auth required
|
||||
* @route {DELETE} /articles/:slug/comments/:id
|
||||
* @param slug slug of the article (based on the title)
|
||||
* @param id id of the comment
|
||||
*/
|
||||
router.delete(
|
||||
'/articles/:slug/comments/:id',
|
||||
auth.required,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await deleteComment(Number(req.params.id), req.auth?.user?.id);
|
||||
res.status(200).json({});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Favorite article
|
||||
* @auth required
|
||||
* @route {POST} /articles/:slug/favorite
|
||||
* @param slug slug of the article (based on the title)
|
||||
* @returns article favorited article
|
||||
*/
|
||||
router.post(
|
||||
'/articles/:slug/favorite',
|
||||
auth.required,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const article = await favoriteArticle(req.params.slug, req.auth?.user?.id);
|
||||
res.json({ article });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Unfavorite article
|
||||
* @auth required
|
||||
* @route {DELETE} /articles/:slug/favorite
|
||||
* @param slug slug of the article (based on the title)
|
||||
* @returns article unfavorited article
|
||||
*/
|
||||
router.delete(
|
||||
'/articles/:slug/favorite',
|
||||
auth.required,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const article = await unfavoriteArticle(req.params.slug, req.auth?.user?.id);
|
||||
res.json({ article });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
16
src/app/routes/article/article.mapper.ts
Normal file
16
src/app/routes/article/article.mapper.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import authorMapper from './author.mapper';
|
||||
|
||||
const articleMapper = (article: any, id?: number) => ({
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
description: article.description,
|
||||
body: article.body,
|
||||
tagList: article.tagList.map((tag: any) => tag.name),
|
||||
createdAt: article.createdAt,
|
||||
updatedAt: article.updatedAt,
|
||||
favorited: article.favoritedBy.some((item: any) => item.id === id),
|
||||
favoritesCount: article.favoritedBy.length,
|
||||
author: authorMapper(article.author, id),
|
||||
});
|
||||
|
||||
export default articleMapper;
|
||||
10
src/app/routes/article/article.model.ts
Normal file
10
src/app/routes/article/article.model.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Comment } from './comment.model';
|
||||
|
||||
export interface Article {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
comments: Comment[];
|
||||
favorited: boolean;
|
||||
}
|
||||
652
src/app/routes/article/article.service.ts
Normal file
652
src/app/routes/article/article.service.ts
Normal file
@@ -0,0 +1,652 @@
|
||||
import slugify from 'slugify';
|
||||
import prisma from '../../../prisma/prisma-client';
|
||||
import HttpException from '../../models/http-exception.model';
|
||||
import profileMapper from '../profile/profile.utils';
|
||||
import articleMapper from './article.mapper';
|
||||
import { Tag } from '../tag/tag.model';
|
||||
|
||||
const buildFindAllQuery = (query: any, id: number | undefined) => {
|
||||
const queries: any = [];
|
||||
const orAuthorQuery = [];
|
||||
const andAuthorQuery = [];
|
||||
|
||||
orAuthorQuery.push({
|
||||
demo: {
|
||||
equals: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (id) {
|
||||
orAuthorQuery.push({
|
||||
id: {
|
||||
equals: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if ('author' in query) {
|
||||
andAuthorQuery.push({
|
||||
username: {
|
||||
equals: query.author,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const authorQuery = {
|
||||
author: {
|
||||
OR: orAuthorQuery,
|
||||
AND: andAuthorQuery,
|
||||
},
|
||||
};
|
||||
|
||||
queries.push(authorQuery);
|
||||
|
||||
if ('tag' in query) {
|
||||
queries.push({
|
||||
tagList: {
|
||||
some: {
|
||||
name: query.tag,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if ('favorited' in query) {
|
||||
queries.push({
|
||||
favoritedBy: {
|
||||
some: {
|
||||
username: {
|
||||
equals: query.favorited,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return queries;
|
||||
};
|
||||
|
||||
export const getArticles = async (query: any, id?: number) => {
|
||||
const andQueries = buildFindAllQuery(query, id);
|
||||
const articlesCount = await prisma.article.count({
|
||||
where: {
|
||||
AND: andQueries,
|
||||
},
|
||||
});
|
||||
|
||||
const articles = await prisma.article.findMany({
|
||||
where: { AND: andQueries },
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip: Number(query.offset) || 0,
|
||||
take: Number(query.limit) || 10,
|
||||
include: {
|
||||
tagList: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
followedBy: true,
|
||||
},
|
||||
},
|
||||
favoritedBy: true,
|
||||
_count: {
|
||||
select: {
|
||||
favoritedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
articles: articles.map((article: any) => articleMapper(article, id)),
|
||||
articlesCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const getFeed = async (offset: number, limit: number, id: number) => {
|
||||
const articlesCount = await prisma.article.count({
|
||||
where: {
|
||||
author: {
|
||||
followedBy: { some: { id: id } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const articles = await prisma.article.findMany({
|
||||
where: {
|
||||
author: {
|
||||
followedBy: { some: { id: id } },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip: offset || 0,
|
||||
take: limit || 10,
|
||||
include: {
|
||||
tagList: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
followedBy: true,
|
||||
},
|
||||
},
|
||||
favoritedBy: true,
|
||||
_count: {
|
||||
select: {
|
||||
favoritedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
articles: articles.map((article: any) => articleMapper(article, id)),
|
||||
articlesCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const createArticle = async (article: any, id: number) => {
|
||||
const { title, description, body, tagList } = article;
|
||||
const tags = Array.isArray(tagList) ? tagList : [];
|
||||
|
||||
if (!title) {
|
||||
throw new HttpException(422, { errors: { title: ["can't be blank"] } });
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
throw new HttpException(422, { errors: { description: ["can't be blank"] } });
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
throw new HttpException(422, { errors: { body: ["can't be blank"] } });
|
||||
}
|
||||
|
||||
const slug = `${slugify(title)}-${id}`;
|
||||
|
||||
const existingTitle = await prisma.article.findUnique({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTitle) {
|
||||
throw new HttpException(422, { errors: { title: ['must be unique'] } });
|
||||
}
|
||||
|
||||
const {
|
||||
authorId,
|
||||
id: articleId,
|
||||
...createdArticle
|
||||
} = await prisma.article.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
body,
|
||||
slug,
|
||||
tagList: {
|
||||
connectOrCreate: tags.map((tag: string) => ({
|
||||
create: { name: tag },
|
||||
where: { name: tag },
|
||||
})),
|
||||
},
|
||||
author: {
|
||||
connect: {
|
||||
id: id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tagList: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
followedBy: true,
|
||||
},
|
||||
},
|
||||
favoritedBy: true,
|
||||
_count: {
|
||||
select: {
|
||||
favoritedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return articleMapper(createdArticle, id);
|
||||
};
|
||||
|
||||
export const getArticle = async (slug: string, id?: number) => {
|
||||
const article = await prisma.article.findUnique({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
include: {
|
||||
tagList: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
followedBy: true,
|
||||
},
|
||||
},
|
||||
favoritedBy: true,
|
||||
_count: {
|
||||
select: {
|
||||
favoritedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
throw new HttpException(404, { errors: { article: ['not found'] } });
|
||||
}
|
||||
|
||||
return articleMapper(article, id);
|
||||
};
|
||||
|
||||
const disconnectArticlesTags = async (slug: string) => {
|
||||
await prisma.article.update({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
data: {
|
||||
tagList: {
|
||||
set: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const updateArticle = async (article: any, slug: string, id: number) => {
|
||||
let newSlug = null;
|
||||
|
||||
const existingArticle = await await prisma.article.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
select: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingArticle) {
|
||||
throw new HttpException(404, {});
|
||||
}
|
||||
|
||||
if (existingArticle.author.id !== id) {
|
||||
throw new HttpException(403, {
|
||||
message: 'You are not authorized to update this article',
|
||||
});
|
||||
}
|
||||
|
||||
if (article.title) {
|
||||
newSlug = `${slugify(article.title)}-${id}`;
|
||||
|
||||
if (newSlug !== slug) {
|
||||
const existingTitle = await prisma.article.findFirst({
|
||||
where: {
|
||||
slug: newSlug,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTitle) {
|
||||
throw new HttpException(422, { errors: { title: ['must be unique'] } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tagList =
|
||||
Array.isArray(article.tagList) && article.tagList?.length
|
||||
? article.tagList.map((tag: string) => ({
|
||||
create: { name: tag },
|
||||
where: { name: tag },
|
||||
}))
|
||||
: [];
|
||||
|
||||
await disconnectArticlesTags(slug);
|
||||
|
||||
const updatedArticle = await prisma.article.update({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
data: {
|
||||
...(article.title ? { title: article.title } : {}),
|
||||
...(article.body ? { body: article.body } : {}),
|
||||
...(article.description ? { description: article.description } : {}),
|
||||
...(newSlug ? { slug: newSlug } : {}),
|
||||
updatedAt: new Date(),
|
||||
tagList: {
|
||||
connectOrCreate: tagList,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tagList: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
followedBy: true,
|
||||
},
|
||||
},
|
||||
favoritedBy: true,
|
||||
_count: {
|
||||
select: {
|
||||
favoritedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return articleMapper(updatedArticle, id);
|
||||
};
|
||||
|
||||
export const deleteArticle = async (slug: string, id: number) => {
|
||||
const existingArticle = await await prisma.article.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
select: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingArticle) {
|
||||
throw new HttpException(404, {});
|
||||
}
|
||||
|
||||
if (existingArticle.author.id !== id) {
|
||||
throw new HttpException(403, {
|
||||
message: 'You are not authorized to delete this article',
|
||||
});
|
||||
}
|
||||
await prisma.article.delete({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getCommentsByArticle = async (slug: string, id?: number) => {
|
||||
const queries = [];
|
||||
|
||||
queries.push({
|
||||
author: {
|
||||
demo: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (id) {
|
||||
queries.push({
|
||||
author: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const comments = await prisma.article.findUnique({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
include: {
|
||||
comments: {
|
||||
where: {
|
||||
OR: queries,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
body: true,
|
||||
author: {
|
||||
select: {
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
followedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = comments?.comments.map((comment: any) => ({
|
||||
...comment,
|
||||
author: {
|
||||
username: comment.author.username,
|
||||
bio: comment.author.bio,
|
||||
image: comment.author.image,
|
||||
following: comment.author.followedBy.some((follow: any) => follow.id === id),
|
||||
},
|
||||
}));
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const addComment = async (body: string, slug: string, id: number) => {
|
||||
if (!body) {
|
||||
throw new HttpException(422, { errors: { body: ["can't be blank"] } });
|
||||
}
|
||||
|
||||
const article = await prisma.article.findUnique({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const comment = await prisma.comment.create({
|
||||
data: {
|
||||
body,
|
||||
article: {
|
||||
connect: {
|
||||
id: article?.id,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
connect: {
|
||||
id: id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
followedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
body: comment.body,
|
||||
author: {
|
||||
username: comment.author.username,
|
||||
bio: comment.author.bio,
|
||||
image: comment.author.image,
|
||||
following: comment.author.followedBy.some((follow: any) => follow.id === id),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteComment = async (id: number, userId: number) => {
|
||||
const comment = await prisma.comment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
author: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!comment) {
|
||||
throw new HttpException(404, {});
|
||||
}
|
||||
|
||||
if (comment.author.id !== userId) {
|
||||
throw new HttpException(403, {
|
||||
message: 'You are not authorized to delete this comment',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.comment.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const favoriteArticle = async (slugPayload: string, id: number) => {
|
||||
const { _count, ...article } = await prisma.article.update({
|
||||
where: {
|
||||
slug: slugPayload,
|
||||
},
|
||||
data: {
|
||||
favoritedBy: {
|
||||
connect: {
|
||||
id: id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tagList: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
followedBy: true,
|
||||
},
|
||||
},
|
||||
favoritedBy: true,
|
||||
_count: {
|
||||
select: {
|
||||
favoritedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = {
|
||||
...article,
|
||||
author: profileMapper(article.author, id),
|
||||
tagList: article?.tagList.map((tag: Tag) => tag.name),
|
||||
favorited: article.favoritedBy.some((favorited: any) => favorited.id === id),
|
||||
favoritesCount: _count?.favoritedBy,
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const unfavoriteArticle = async (slugPayload: string, id: number) => {
|
||||
const { _count, ...article } = await prisma.article.update({
|
||||
where: {
|
||||
slug: slugPayload,
|
||||
},
|
||||
data: {
|
||||
favoritedBy: {
|
||||
disconnect: {
|
||||
id: id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tagList: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
followedBy: true,
|
||||
},
|
||||
},
|
||||
favoritedBy: true,
|
||||
_count: {
|
||||
select: {
|
||||
favoritedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = {
|
||||
...article,
|
||||
author: profileMapper(article.author, id),
|
||||
tagList: article?.tagList.map((tag: Tag) => tag.name),
|
||||
favorited: article.favoritedBy.some((favorited: any) => favorited.id === id),
|
||||
favoritesCount: _count?.favoritedBy,
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
12
src/app/routes/article/author.mapper.ts
Normal file
12
src/app/routes/article/author.mapper.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { User } from '../auth/user.model';
|
||||
|
||||
const authorMapper = (author: any, id?: number) => ({
|
||||
username: author.username,
|
||||
bio: author.bio,
|
||||
image: author.image,
|
||||
following: id
|
||||
? author?.followedBy.some((followingUser: Partial<User>) => followingUser.id === id)
|
||||
: false,
|
||||
});
|
||||
|
||||
export default authorMapper;
|
||||
9
src/app/routes/article/comment.model.ts
Normal file
9
src/app/routes/article/comment.model.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Article } from './article.model';
|
||||
|
||||
export interface Comment {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
body: string;
|
||||
article?: Article;
|
||||
}
|
||||
70
src/app/routes/auth/auth.controller.ts
Normal file
70
src/app/routes/auth/auth.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import auth from './auth';
|
||||
import { createUser, getCurrentUser, login, updateUser } from './auth.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Create an user
|
||||
* @auth none
|
||||
* @route {POST} /users
|
||||
* @bodyparam user User
|
||||
* @returns user User
|
||||
*/
|
||||
router.post('/users', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await createUser({ ...req.body.user, demo: false });
|
||||
res.status(201).json({ user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Login
|
||||
* @auth none
|
||||
* @route {POST} /users/login
|
||||
* @bodyparam user User
|
||||
* @returns user User
|
||||
*/
|
||||
router.post('/users/login', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await login(req.body.user);
|
||||
res.json({ user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
* @auth required
|
||||
* @route {GET} /user
|
||||
* @returns user User
|
||||
*/
|
||||
router.get('/user', auth.required, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await getCurrentUser(req.auth?.user?.id);
|
||||
res.json({ user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update user
|
||||
* @auth required
|
||||
* @route {PUT} /user
|
||||
* @bodyparam user User
|
||||
* @returns user User
|
||||
*/
|
||||
router.put('/user', auth.required, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await updateUser(req.body.user, req.auth?.user?.id);
|
||||
res.json({ user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
183
src/app/routes/auth/auth.service.ts
Normal file
183
src/app/routes/auth/auth.service.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { RegisterInput } from './register-input.model';
|
||||
import prisma from '../../../prisma/prisma-client';
|
||||
import HttpException from '../../models/http-exception.model';
|
||||
import { RegisteredUser } from './registered-user.model';
|
||||
import generateToken from './token.utils';
|
||||
import { User } from './user.model';
|
||||
|
||||
const checkUserUniqueness = async (email: string, username: string) => {
|
||||
const existingUserByEmail = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const existingUserByUsername = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserByEmail || existingUserByUsername) {
|
||||
throw new HttpException(422, {
|
||||
errors: {
|
||||
...(existingUserByEmail ? { email: ['has already been taken'] } : {}),
|
||||
...(existingUserByUsername ? { username: ['has already been taken'] } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createUser = async (input: RegisterInput): Promise<RegisteredUser> => {
|
||||
const email = input.email?.trim();
|
||||
const username = input.username?.trim();
|
||||
const password = input.password?.trim();
|
||||
const { image, bio, demo } = input;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpException(422, { errors: { email: ["can't be blank"] } });
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
throw new HttpException(422, { errors: { username: ["can't be blank"] } });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
throw new HttpException(422, { errors: { password: ["can't be blank"] } });
|
||||
}
|
||||
|
||||
await checkUserUniqueness(email, username);
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
...(image ? { image } : {}),
|
||||
...(bio ? { bio } : {}),
|
||||
...(demo ? { demo } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...user,
|
||||
token: generateToken(user.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const login = async (userPayload: any) => {
|
||||
const email = userPayload.email?.trim();
|
||||
const password = userPayload.password?.trim();
|
||||
|
||||
if (!email) {
|
||||
throw new HttpException(422, { errors: { email: ["can't be blank"] } });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
throw new HttpException(422, { errors: { password: ["can't be blank"] } });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
password: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const match = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
image: user.image,
|
||||
token: generateToken(user.id),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpException(403, {
|
||||
errors: {
|
||||
'email or password': ['is invalid'],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getCurrentUser = async (id: number) => {
|
||||
const user = (await prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
},
|
||||
})) as User;
|
||||
|
||||
return {
|
||||
...user,
|
||||
token: generateToken(user.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const updateUser = async (userPayload: any, id: number) => {
|
||||
const { email, username, password, image, bio } = userPayload;
|
||||
let hashedPassword;
|
||||
|
||||
if (password) {
|
||||
hashedPassword = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
...(email ? { email } : {}),
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password: hashedPassword } : {}),
|
||||
...(image ? { image } : {}),
|
||||
...(bio ? { bio } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...user,
|
||||
token: generateToken(user.id),
|
||||
};
|
||||
};
|
||||
28
src/app/routes/auth/auth.ts
Normal file
28
src/app/routes/auth/auth.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { expressjwt as jwt } from 'express-jwt';
|
||||
import * as express from 'express';
|
||||
|
||||
const getTokenFromHeaders = (req: express.Request): string | null => {
|
||||
if (
|
||||
(req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Token') ||
|
||||
(req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer')
|
||||
) {
|
||||
return req.headers.authorization.split(' ')[1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const auth = {
|
||||
required: jwt({
|
||||
secret: process.env.JWT_SECRET || 'superSecret',
|
||||
getToken: getTokenFromHeaders,
|
||||
algorithms: ['HS256'],
|
||||
}),
|
||||
optional: jwt({
|
||||
secret: process.env.JWT_SECRET || 'superSecret',
|
||||
credentialsRequired: false,
|
||||
getToken: getTokenFromHeaders,
|
||||
algorithms: ['HS256'],
|
||||
}),
|
||||
};
|
||||
|
||||
export default auth;
|
||||
8
src/app/routes/auth/register-input.model.ts
Normal file
8
src/app/routes/auth/register-input.model.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface RegisterInput {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
image?: string;
|
||||
bio?: string;
|
||||
demo?: boolean;
|
||||
}
|
||||
8
src/app/routes/auth/registered-user.model.ts
Normal file
8
src/app/routes/auth/registered-user.model.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface RegisteredUser {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
bio: string | null;
|
||||
image: string | null;
|
||||
token: string;
|
||||
}
|
||||
8
src/app/routes/auth/token.utils.ts
Normal file
8
src/app/routes/auth/token.utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
const generateToken = (id: number): string =>
|
||||
jwt.sign({ user: { id } }, process.env.JWT_SECRET || 'superSecret', {
|
||||
expiresIn: '60d',
|
||||
});
|
||||
|
||||
export default generateToken;
|
||||
9
src/app/routes/auth/user-request.d.ts
vendored
Normal file
9
src/app/routes/auth/user-request.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare namespace Express {
|
||||
export interface Request {
|
||||
auth?: {
|
||||
user?: {
|
||||
id?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
17
src/app/routes/auth/user.model.ts
Normal file
17
src/app/routes/auth/user.model.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Article } from '../article/article.model';
|
||||
import { Comment } from '../article/comment.model';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
bio: string | null;
|
||||
image: any | null;
|
||||
articles: Article[];
|
||||
favorites: Article[];
|
||||
followedBy: User[];
|
||||
following: User[];
|
||||
comments: Comment[];
|
||||
demo: boolean;
|
||||
}
|
||||
67
src/app/routes/profile/profile.controller.ts
Normal file
67
src/app/routes/profile/profile.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import auth from '../auth/auth';
|
||||
import { followUser, getProfile, unfollowUser } from './profile.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Get profile
|
||||
* @auth optional
|
||||
* @route {GET} /profiles/:username
|
||||
* @param username string
|
||||
* @returns profile
|
||||
*/
|
||||
router.get(
|
||||
'/profiles/:username',
|
||||
auth.optional,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const profile = await getProfile(req.params.username, req.auth?.user?.id);
|
||||
res.json({ profile });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Follow user
|
||||
* @auth required
|
||||
* @route {POST} /profiles/:username/follow
|
||||
* @param username string
|
||||
* @returns profile
|
||||
*/
|
||||
router.post(
|
||||
'/profiles/:username/follow',
|
||||
auth.required,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const profile = await followUser(req.params?.username, req.auth?.user?.id);
|
||||
res.json({ profile });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Unfollow user
|
||||
* @auth required
|
||||
* @route {DELETE} /profiles/:username/follow
|
||||
* @param username string
|
||||
* @returns profiles
|
||||
*/
|
||||
router.delete(
|
||||
'/profiles/:username/follow',
|
||||
auth.required,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const profile = await unfollowUser(req.params.username, req.auth?.user?.id);
|
||||
res.json({ profile });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
6
src/app/routes/profile/profile.model.ts
Normal file
6
src/app/routes/profile/profile.model.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface Profile {
|
||||
username: string;
|
||||
bio: string;
|
||||
image: string;
|
||||
following: boolean;
|
||||
}
|
||||
60
src/app/routes/profile/profile.service.ts
Normal file
60
src/app/routes/profile/profile.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import prisma from '../../../prisma/prisma-client';
|
||||
import profileMapper from './profile.utils';
|
||||
import HttpException from '../../models/http-exception.model';
|
||||
|
||||
export const getProfile = async (usernamePayload: string, id?: number) => {
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: usernamePayload,
|
||||
},
|
||||
include: {
|
||||
followedBy: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new HttpException(404, {});
|
||||
}
|
||||
|
||||
return profileMapper(profile, id);
|
||||
};
|
||||
|
||||
export const followUser = async (usernamePayload: string, id: number) => {
|
||||
const profile = await prisma.user.update({
|
||||
where: {
|
||||
username: usernamePayload,
|
||||
},
|
||||
data: {
|
||||
followedBy: {
|
||||
connect: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
followedBy: true,
|
||||
},
|
||||
});
|
||||
|
||||
return profileMapper(profile, id);
|
||||
};
|
||||
|
||||
export const unfollowUser = async (usernamePayload: string, id: number) => {
|
||||
const profile = await prisma.user.update({
|
||||
where: {
|
||||
username: usernamePayload,
|
||||
},
|
||||
data: {
|
||||
followedBy: {
|
||||
disconnect: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
followedBy: true,
|
||||
},
|
||||
});
|
||||
|
||||
return profileMapper(profile, id);
|
||||
};
|
||||
13
src/app/routes/profile/profile.utils.ts
Normal file
13
src/app/routes/profile/profile.utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { User } from '../auth/user.model';
|
||||
import { Profile } from './profile.model';
|
||||
|
||||
const profileMapper = (user: any, id: number | undefined): Profile => ({
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
image: user.image,
|
||||
following: id
|
||||
? user?.followedBy.some((followingUser: Partial<User>) => followingUser.id === id)
|
||||
: false,
|
||||
});
|
||||
|
||||
export default profileMapper;
|
||||
13
src/app/routes/routes.ts
Normal file
13
src/app/routes/routes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import tagsController from './tag/tag.controller';
|
||||
import articlesController from './article/article.controller';
|
||||
import authController from './auth/auth.controller';
|
||||
import profileController from './profile/profile.controller';
|
||||
|
||||
const api = Router()
|
||||
.use(tagsController)
|
||||
.use(articlesController)
|
||||
.use(profileController)
|
||||
.use(authController);
|
||||
|
||||
export default Router().use('/api', api);
|
||||
22
src/app/routes/tag/tag.controller.ts
Normal file
22
src/app/routes/tag/tag.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import auth from '../auth/auth';
|
||||
import getTags from './tag.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Get top 10 popular tags
|
||||
* @auth optional
|
||||
* @route {GET} /api/tags
|
||||
* @returns tags list of tag names
|
||||
*/
|
||||
router.get('/tags', auth.optional, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tags = await getTags(req.auth?.user?.id);
|
||||
res.json({ tags });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
3
src/app/routes/tag/tag.model.ts
Normal file
3
src/app/routes/tag/tag.model.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface Tag {
|
||||
name: string;
|
||||
}
|
||||
40
src/app/routes/tag/tag.service.ts
Normal file
40
src/app/routes/tag/tag.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import prisma from '../../../prisma/prisma-client';
|
||||
import { Tag } from './tag.model';
|
||||
|
||||
const getTags = async (id?: number): Promise<string[]> => {
|
||||
const queries = [];
|
||||
queries.push({ demo: true });
|
||||
|
||||
if (id) {
|
||||
queries.push({
|
||||
id: {
|
||||
equals: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
articles: {
|
||||
some: {
|
||||
author: {
|
||||
OR: queries,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
orderBy: {
|
||||
articles: {
|
||||
_count: 'desc',
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
return tags.map((tag: Tag) => tag.name);
|
||||
};
|
||||
|
||||
export default getTags;
|
||||
BIN
src/assets/images/demo-avatar.png
Normal file
BIN
src/assets/images/demo-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/assets/images/smiley-cyrus.jpeg
Normal file
BIN
src/assets/images/smiley-cyrus.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
19
src/checkstyle/nohttp-checkstyle-suppressions.xml
Normal file
19
src/checkstyle/nohttp-checkstyle-suppressions.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE suppressions PUBLIC
|
||||
"-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
|
||||
"https://checkstyle.org/dtds/suppressions_1_2.dtd">
|
||||
<suppressions>
|
||||
<!-- Upstream defaults -->
|
||||
<suppress files="node_modules[\\/].*" checks=".*"/>
|
||||
<suppress files="node[\\/].*" checks=".*"/>
|
||||
<suppress files="build[\\/].*" checks=".*"/>
|
||||
<suppress files="target[\\/].*" checks=".*"/>
|
||||
<suppress files=".+\.(jar|git|ico|p12|gif|jks|jpg|svg|log)" checks="NoHttp"/>
|
||||
|
||||
<!-- Platform-scaffolded paths that use internal Kubernetes service URLs (http only) -->
|
||||
<suppress files="k6[\\/].*" checks="NoHttp"/>
|
||||
<suppress files="overlays[\\/].*" checks="NoHttp"/>
|
||||
<suppress files="docs[\\/].*" checks="NoHttp"/>
|
||||
<suppress files="config[\\/].*" checks="NoHttp"/>
|
||||
<suppress files="(^|[\\/])score\.yaml$" checks="NoHttp"/>
|
||||
</suppressions>
|
||||
57
src/main.ts
Normal file
57
src/main.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import * as bodyParser from 'body-parser';
|
||||
import routes from './app/routes/routes';
|
||||
import HttpException from './app/models/http-exception.model';
|
||||
|
||||
const app = express();
|
||||
|
||||
/**
|
||||
* App Configuration
|
||||
*/
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
app.use(routes);
|
||||
|
||||
// Serves images
|
||||
app.use(express.static(__dirname + '/assets'));
|
||||
|
||||
app.get('/', (req: express.Request, res: express.Response) => {
|
||||
res.json({ status: 'API is running on /api' });
|
||||
});
|
||||
|
||||
/* eslint-disable */
|
||||
app.use(
|
||||
(
|
||||
err: Error | HttpException,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
// @ts-ignore
|
||||
if (err && err.name === 'UnauthorizedError') {
|
||||
return res.status(401).json({
|
||||
status: 'error',
|
||||
message: 'missing authorization credentials',
|
||||
});
|
||||
// @ts-ignore
|
||||
} else if (err && err.errorCode) {
|
||||
// @ts-ignore
|
||||
res.status(err.errorCode).json(err.message);
|
||||
} else if (err) {
|
||||
res.status(500).json(err.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Server activation
|
||||
*/
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.info(`server up on port ${PORT}`);
|
||||
});
|
||||
114
src/prisma/migrations/20210924225358_initial/migration.sql
Normal file
114
src/prisma/migrations/20210924225358_initial/migration.sql
Normal file
@@ -0,0 +1,114 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Article" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"authorId" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ArticleTags" (
|
||||
"articleId" INTEGER NOT NULL,
|
||||
"tagId" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("articleId","tagId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"body" TEXT NOT NULL,
|
||||
"articleId" INTEGER NOT NULL,
|
||||
"authorId" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tag" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"image" TEXT DEFAULT E'https://realworld-temp-api.herokuapp.com/images/smiley-cyrus.jpeg',
|
||||
"bio" TEXT,
|
||||
"demo" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_UserFavorites" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_UserFollows" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Article.slug_unique" ON "Article"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User.email_unique" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User.username_unique" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_UserFavorites_AB_unique" ON "_UserFavorites"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_UserFavorites_B_index" ON "_UserFavorites"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_UserFollows_AB_unique" ON "_UserFollows"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_UserFollows_B_index" ON "_UserFollows"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Article" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ArticleTags" ADD FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ArticleTags" ADD FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserFavorites" ADD FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserFavorites" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserFollows" ADD FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserFollows" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `ArticleTags` table. If the table is not empty, all the data it contains will be lost.
|
||||
- A unique constraint covering the columns `[name]` on the table `Tag` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ArticleTags" DROP CONSTRAINT "ArticleTags_articleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ArticleTags" DROP CONSTRAINT "ArticleTags_tagId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "ArticleTags";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ArticleToTag" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ArticleToTag_AB_unique" ON "_ArticleToTag"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ArticleToTag_B_index" ON "_ArticleToTag"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Tag.name_unique" ON "Tag"("name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArticleToTag" ADD FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArticleToTag" ADD FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "image" SET DEFAULT E'https://api.realworld.io/images/smiley-cyrus.jpeg';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- RenameIndex
|
||||
ALTER INDEX "Article.slug_unique" RENAME TO "Article_slug_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "Tag.name_unique" RENAME TO "Tag_name_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "User.email_unique" RENAME TO "User_email_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "User.username_unique" RENAME TO "User_username_key";
|
||||
3
src/prisma/migrations/migration_lock.toml
Normal file
3
src/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
23
src/prisma/prisma-client.ts
Normal file
23
src/prisma/prisma-client.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Global {}
|
||||
}
|
||||
}
|
||||
|
||||
// add prisma to the NodeJS global type
|
||||
interface CustomNodeJsGlobal extends NodeJS.Global {
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
|
||||
// Prevent multiple instances of Prisma Client in development
|
||||
declare const global: CustomNodeJsGlobal;
|
||||
|
||||
const prisma = global.prisma || new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
global.prisma = prisma;
|
||||
}
|
||||
|
||||
export default prisma;
|
||||
56
src/prisma/schema.prisma
Normal file
56
src/prisma/schema.prisma
Normal file
@@ -0,0 +1,56 @@
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = []
|
||||
}
|
||||
|
||||
model Article {
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique
|
||||
title String
|
||||
description String
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
tagList Tag[]
|
||||
author User @relation("UserArticles", fields: [authorId], onDelete: Cascade, references: [id])
|
||||
authorId Int
|
||||
favoritedBy User[] @relation("UserFavorites")
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
body String
|
||||
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
|
||||
articleId Int
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
authorId Int
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
articles Article[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
username String @unique
|
||||
password String
|
||||
image String? @default("https://api.realworld.io/images/smiley-cyrus.jpeg")
|
||||
bio String?
|
||||
articles Article[] @relation("UserArticles")
|
||||
favorites Article[] @relation("UserFavorites")
|
||||
followedBy User[] @relation("UserFollows")
|
||||
following User[] @relation("UserFollows")
|
||||
comments Comment[]
|
||||
demo Boolean @default(false)
|
||||
}
|
||||
66
src/prisma/seed.ts
Normal file
66
src/prisma/seed.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
randEmail,
|
||||
randFullName,
|
||||
randLines,
|
||||
randParagraph,
|
||||
randPassword, randPhrase,
|
||||
randWord
|
||||
} from '@ngneat/falso';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { RegisteredUser } from '../app/routes/auth/registered-user.model';
|
||||
import { createUser } from '../app/routes/auth/auth.service';
|
||||
import { addComment, createArticle } from '../app/routes/article/article.service';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export const generateUser = async (): Promise<RegisteredUser> =>
|
||||
createUser({
|
||||
username: randFullName(),
|
||||
email: randEmail(),
|
||||
password: randPassword(),
|
||||
image: 'https://api.realworld.io/images/demo-avatar.png',
|
||||
demo: true,
|
||||
});
|
||||
|
||||
export const generateArticle = async (id: number) =>
|
||||
createArticle(
|
||||
{
|
||||
title: randPhrase(),
|
||||
description: randParagraph(),
|
||||
body: randLines({ length: 10 }).join(' '),
|
||||
tagList: randWord({ length: 4 }),
|
||||
},
|
||||
id,
|
||||
);
|
||||
|
||||
export const generateComment = async (id: number, slug: string) =>
|
||||
addComment(randParagraph(), slug, id);
|
||||
|
||||
const main = async () => {
|
||||
try {
|
||||
const users = await Promise.all(Array.from({length: 12}, () => generateUser()));
|
||||
users?.map(user => user);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for await (const user of users) {
|
||||
const articles = await Promise.all(Array.from({length: 12}, () => generateArticle(user.id)));
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for await (const article of articles) {
|
||||
await Promise.all(users.map(userItem => generateComment(userItem.id, article.slug)));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
17
src/tests/prisma-mock.ts
Normal file
17
src/tests/prisma-mock.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';
|
||||
|
||||
import prisma from '../prisma/prisma-client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
jest.mock('../prisma/prisma-client', () => ({
|
||||
__esModule: true,
|
||||
default: mockDeep<PrismaClient>(),
|
||||
}));
|
||||
|
||||
const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(prismaMock);
|
||||
});
|
||||
|
||||
export default prismaMock;
|
||||
144
src/tests/services/article.service.test.ts
Normal file
144
src/tests/services/article.service.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import prismaMock from '../prisma-mock';
|
||||
import {
|
||||
deleteComment,
|
||||
favoriteArticle,
|
||||
unfavoriteArticle,
|
||||
} from '../../app/routes/article/article.service';
|
||||
|
||||
describe('ArticleService', () => {
|
||||
describe('deleteComment', () => {
|
||||
test('should throw an error ', () => {
|
||||
// Given
|
||||
const id = 123;
|
||||
const idUser = 456;
|
||||
|
||||
// When
|
||||
// @ts-ignore
|
||||
prismaMock.comment.findFirst.mockResolvedValue(null);
|
||||
|
||||
// Then
|
||||
expect(deleteComment(id, idUser)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe('favoriteArticle', () => {
|
||||
test('should return the favorited article', async () => {
|
||||
// Given
|
||||
const slug = 'How-to-train-your-dragon';
|
||||
const username = 'RealWorld';
|
||||
|
||||
const mockedUserResponse = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
};
|
||||
|
||||
const mockedArticleResponse = {
|
||||
id: 123,
|
||||
slug: 'How-to-train-your-dragon',
|
||||
title: 'How to train your dragon',
|
||||
description: '',
|
||||
body: '',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorId: 456,
|
||||
tagList: [],
|
||||
favoritedBy: [],
|
||||
author: {
|
||||
username: 'RealWorld',
|
||||
bio: null,
|
||||
image: null,
|
||||
followedBy: [],
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
// @ts-ignore
|
||||
prismaMock.user.findUnique.mockResolvedValue(mockedUserResponse);
|
||||
// @ts-ignore
|
||||
prismaMock.article.update.mockResolvedValue(mockedArticleResponse);
|
||||
|
||||
// Then
|
||||
await expect(favoriteArticle(slug, mockedUserResponse.id)).resolves.toHaveProperty(
|
||||
'favoritesCount',
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw an error if no user is found', async () => {
|
||||
// Given
|
||||
const id = 123;
|
||||
const slug = 'how-to-train-your-dragon';
|
||||
const username = 'RealWorld';
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
// Then
|
||||
await expect(favoriteArticle(slug, id)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
describe('unfavoriteArticle', () => {
|
||||
test('should return the unfavorited article', async () => {
|
||||
// Given
|
||||
const slug = 'How-to-train-your-dragon';
|
||||
const username = 'RealWorld';
|
||||
|
||||
const mockedUserResponse = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
};
|
||||
|
||||
const mockedArticleResponse = {
|
||||
id: 123,
|
||||
slug: 'How-to-train-your-dragon',
|
||||
title: 'How to train your dragon',
|
||||
description: '',
|
||||
body: '',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorId: 456,
|
||||
tagList: [],
|
||||
favoritedBy: [],
|
||||
author: {
|
||||
username: 'RealWorld',
|
||||
bio: null,
|
||||
image: null,
|
||||
followedBy: [],
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(mockedUserResponse);
|
||||
prismaMock.article.update.mockResolvedValue(mockedArticleResponse);
|
||||
|
||||
// Then
|
||||
await expect(unfavoriteArticle(slug, mockedUserResponse.id)).resolves.toHaveProperty(
|
||||
'favoritesCount',
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw an error if no user is found', async () => {
|
||||
// Given
|
||||
const id = 123;
|
||||
const slug = 'how-to-train-your-dragon';
|
||||
const username = 'RealWorld';
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
// Then
|
||||
await expect(unfavoriteArticle(slug, id)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
});
|
||||
254
src/tests/services/auth.service.test.ts
Normal file
254
src/tests/services/auth.service.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { createUser, getCurrentUser, login, updateUser } from '../../app/routes/auth/auth.service';
|
||||
import prismaMock from '../prisma-mock';
|
||||
|
||||
describe('AuthService', () => {
|
||||
describe('createUser', () => {
|
||||
test('should create new user ', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
};
|
||||
|
||||
const mockedResponse = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
};
|
||||
|
||||
// When
|
||||
// @ts-ignore
|
||||
prismaMock.user.create.mockResolvedValue(mockedResponse);
|
||||
|
||||
// Then
|
||||
await expect(createUser(user)).resolves.toHaveProperty('token');
|
||||
});
|
||||
|
||||
test('should throw an error when creating new user with empty username ', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
id: 123,
|
||||
username: ' ',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
};
|
||||
|
||||
// Then
|
||||
const error = String({ errors: { username: ["can't be blank"] } });
|
||||
await expect(createUser(user)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test('should throw an error when creating new user with empty email ', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: ' ',
|
||||
password: '1234',
|
||||
};
|
||||
|
||||
// Then
|
||||
const error = String({ errors: { email: ["can't be blank"] } });
|
||||
await expect(createUser(user)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test('should throw an error when creating new user with empty password ', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: ' ',
|
||||
};
|
||||
|
||||
// Then
|
||||
const error = String({ errors: { password: ["can't be blank"] } });
|
||||
await expect(createUser(user)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test('should throw an exception when creating a new user with already existing user on same username ', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
};
|
||||
|
||||
const mockedExistingUser = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
};
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(mockedExistingUser);
|
||||
|
||||
// Then
|
||||
const error = { email: ['has already been taken'] }.toString();
|
||||
await expect(createUser(user)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
test('should return a token', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
};
|
||||
|
||||
const hashedPassword = await bcrypt.hash(user.password, 10);
|
||||
|
||||
const mockedResponse = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: hashedPassword,
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
};
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
|
||||
|
||||
// Then
|
||||
await expect(login(user)).resolves.toHaveProperty('token');
|
||||
});
|
||||
|
||||
test('should throw an error when the email is empty', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
email: ' ',
|
||||
password: '1234',
|
||||
};
|
||||
|
||||
// Then
|
||||
const error = String({ errors: { email: ["can't be blank"] } });
|
||||
await expect(login(user)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test('should throw an error when the password is empty', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
email: 'realworld@me',
|
||||
password: ' ',
|
||||
};
|
||||
|
||||
// Then
|
||||
const error = String({ errors: { password: ["can't be blank"] } });
|
||||
await expect(login(user)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test('should throw an error when no user is found', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
};
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
// Then
|
||||
const error = String({ errors: { 'email or password': ['is invalid'] } });
|
||||
await expect(login(user)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test('should throw an error if the password is wrong', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
};
|
||||
|
||||
const hashedPassword = await bcrypt.hash('4321', 10);
|
||||
|
||||
const mockedResponse = {
|
||||
id: 123,
|
||||
username: 'Gerome',
|
||||
email: 'realworld@me',
|
||||
password: hashedPassword,
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
};
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
|
||||
|
||||
// Then
|
||||
const error = String({ errors: { 'email or password': ['is invalid'] } });
|
||||
await expect(login(user)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
test('should return a token', async () => {
|
||||
// Given
|
||||
const id = 123;
|
||||
|
||||
const mockedResponse = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
};
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
|
||||
|
||||
// Then
|
||||
await expect(getCurrentUser(id)).resolves.toHaveProperty('token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser', () => {
|
||||
test('should return a token', async () => {
|
||||
// Given
|
||||
const user = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
};
|
||||
|
||||
const mockedResponse = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
};
|
||||
|
||||
// When
|
||||
prismaMock.user.update.mockResolvedValue(mockedResponse);
|
||||
|
||||
// Then
|
||||
await expect(updateUser(user, user.id)).resolves.toHaveProperty('token');
|
||||
});
|
||||
});
|
||||
});
|
||||
145
src/tests/services/profile.service.test.ts
Normal file
145
src/tests/services/profile.service.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import prismaMock from '../prisma-mock';
|
||||
import { followUser, getProfile, unfollowUser } from '../../app/routes/profile/profile.service';
|
||||
|
||||
describe('ProfileService', () => {
|
||||
describe('getProfile', () => {
|
||||
test('should return a following property', async () => {
|
||||
// Given
|
||||
const username = 'RealWorld';
|
||||
const id = 123;
|
||||
|
||||
const mockedResponse = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
followedBy: [],
|
||||
};
|
||||
|
||||
// When
|
||||
// @ts-ignore
|
||||
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
|
||||
|
||||
// Then
|
||||
await expect(getProfile(username, id)).resolves.toHaveProperty('following');
|
||||
});
|
||||
|
||||
test('should throw an error if no user is found', async () => {
|
||||
// Given
|
||||
const username = 'RealWorld';
|
||||
const id = 123;
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
// Then
|
||||
await expect(getProfile(username, id)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe('followUser', () => {
|
||||
test('shoud return a following property', async () => {
|
||||
// Given
|
||||
const usernamePayload = 'AnotherUser';
|
||||
const id = 123;
|
||||
|
||||
const mockedAuthUser = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
followedBy: [],
|
||||
};
|
||||
|
||||
const mockedResponse = {
|
||||
id: 123,
|
||||
username: 'AnotherUser',
|
||||
email: 'another@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
followedBy: [],
|
||||
};
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser);
|
||||
prismaMock.user.update.mockResolvedValue(mockedResponse);
|
||||
|
||||
// Then
|
||||
await expect(followUser(usernamePayload, id)).resolves.toHaveProperty('following');
|
||||
});
|
||||
|
||||
test('shoud throw an error if no user is found', async () => {
|
||||
// Given
|
||||
const usernamePayload = 'AnotherUser';
|
||||
const id = 123;
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
// Then
|
||||
await expect(followUser(usernamePayload, id)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unfollowUser', () => {
|
||||
test('shoud return a following property', async () => {
|
||||
// Given
|
||||
const usernamePayload = 'AnotherUser';
|
||||
const id = 123;
|
||||
|
||||
const mockedAuthUser = {
|
||||
id: 123,
|
||||
username: 'RealWorld',
|
||||
email: 'realworld@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
followedBy: [],
|
||||
};
|
||||
|
||||
const mockedResponse = {
|
||||
id: 123,
|
||||
username: 'AnotherUser',
|
||||
email: 'another@me',
|
||||
password: '1234',
|
||||
bio: null,
|
||||
image: null,
|
||||
token: '',
|
||||
demo: false,
|
||||
followedBy: [],
|
||||
};
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser);
|
||||
prismaMock.user.update.mockResolvedValue(mockedResponse);
|
||||
|
||||
// Then
|
||||
await expect(unfollowUser(usernamePayload, id)).resolves.toHaveProperty('following');
|
||||
});
|
||||
|
||||
test('shoud throw an error if no user is found', async () => {
|
||||
// Given
|
||||
const usernamePayload = 'AnotherUser';
|
||||
const id = 123;
|
||||
|
||||
// When
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
// Then
|
||||
await expect(unfollowUser(usernamePayload, id)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
});
|
||||
6
src/tests/services/tag.service.test.ts
Normal file
6
src/tests/services/tag.service.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
describe('TagService', () => {
|
||||
describe('getTags', () => {
|
||||
// TODO : prismaMock.tag.groupBy.mockResolvedValue(mockedResponse) doesn't work
|
||||
test.todo('should return a list of strings');
|
||||
});
|
||||
});
|
||||
79
src/tests/utils/profile.utils.test.ts
Normal file
79
src/tests/utils/profile.utils.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import profileMapper from '../../app/routes/profile/profile.utils';
|
||||
|
||||
describe('ProfileUtils', () => {
|
||||
describe('profileMapper', () => {
|
||||
test('should return a profile', () => {
|
||||
// Given
|
||||
const user = {
|
||||
username: 'RealWorld',
|
||||
bio: 'My happy life',
|
||||
image: null,
|
||||
followedBy: [],
|
||||
};
|
||||
const id = 123;
|
||||
|
||||
// When
|
||||
const expected = {
|
||||
username: 'RealWorld',
|
||||
bio: 'My happy life',
|
||||
image: null,
|
||||
following: false,
|
||||
};
|
||||
|
||||
// Then
|
||||
expect(profileMapper(user, id)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should return a profile followed by the user', () => {
|
||||
// Given
|
||||
const user = {
|
||||
username: 'RealWorld',
|
||||
bio: 'My happy life',
|
||||
image: null,
|
||||
followedBy: [
|
||||
{
|
||||
id: 123,
|
||||
},
|
||||
],
|
||||
};
|
||||
const id = 123;
|
||||
|
||||
// When
|
||||
const expected = {
|
||||
username: 'RealWorld',
|
||||
bio: 'My happy life',
|
||||
image: null,
|
||||
following: true,
|
||||
};
|
||||
|
||||
// Then
|
||||
expect(profileMapper(user, id)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should return a profile not followed by the user', () => {
|
||||
// Given
|
||||
const user = {
|
||||
username: 'RealWorld',
|
||||
bio: 'My happy life',
|
||||
image: null,
|
||||
followedBy: [
|
||||
{
|
||||
username: 'NotRealWorld',
|
||||
},
|
||||
],
|
||||
};
|
||||
const id = 123;
|
||||
|
||||
// When
|
||||
const expected = {
|
||||
username: 'RealWorld',
|
||||
bio: 'My happy life',
|
||||
image: null,
|
||||
following: false,
|
||||
};
|
||||
|
||||
// Then
|
||||
expect(profileMapper(user, id)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user