diff --git a/src/controller/keyword.controller.ts b/src/controller/keyword.controller.ts index 2a883d4..3cd8d6a 100644 --- a/src/controller/keyword.controller.ts +++ b/src/controller/keyword.controller.ts @@ -46,12 +46,11 @@ const getTopKeywords = async (req: Request, res: Response, next: NextFunction) = try { const topReactionImage: string = await MemeService.getTopReactionImage(keyword); return { ...keyword, topReactionImage } as IKeywordWithImage; - } catch (error) { - logger.error( - `Error retrieving top reaction image for keyword: ${JSON.stringify(keyword._id)}`, - error, + } catch (err) { + throw new CustomError( + `Error retrieving top reaction image for keyword: ${JSON.stringify(keyword._id)}: ${err.message}`, + HttpCode.INTERNAL_SERVER_ERROR, ); - throw new CustomError(`Failed to get top reaction image`, HttpCode.INTERNAL_SERVER_ERROR); } }, ); diff --git a/src/controller/keywordCategory.controller.ts b/src/controller/keywordCategory.controller.ts index 9b27e88..11b4df4 100644 --- a/src/controller/keywordCategory.controller.ts +++ b/src/controller/keywordCategory.controller.ts @@ -30,7 +30,7 @@ const updateKeywordCategory = async (req: CustomRequest, res: Response, next: Ne updateInfo, ); logger.info(`Updated category with ID ${req.params.categoryName}`); - return res.json(createSuccessResponse(HttpCode.OK, 'Update KeywordCategor', updatedCategory)); + return res.json(createSuccessResponse(HttpCode.OK, 'Updated KeywordCategory', updatedCategory)); } catch (err) { return next(new CustomError(err.message, err.status || HttpCode.INTERNAL_SERVER_ERROR)); } @@ -40,7 +40,7 @@ const deleteKeywordCategory = async (req: CustomRequest, res: Response, next: Ne const category = req.requestedKeywordCategory; try { await KeywordCategoryService.deleteKeywordCategory(category.name); - return res.json(createSuccessResponse(HttpCode.OK, 'Deleted KeywordCategor', true)); + return res.json(createSuccessResponse(HttpCode.OK, 'Deleted KeywordCategory', true)); } catch (err) { return next(new CustomError(err.message, err.status || HttpCode.INTERNAL_SERVER_ERROR)); } diff --git a/src/controller/meme.controller.ts b/src/controller/meme.controller.ts index 7f37703..45657af 100644 --- a/src/controller/meme.controller.ts +++ b/src/controller/meme.controller.ts @@ -5,8 +5,11 @@ import mongoose, { Types } from 'mongoose'; import CustomError from '../errors/CustomError'; import { HttpCode } from '../errors/HttpCode'; import { CustomRequest } from '../middleware/requestedInfo'; +import { IKeywordDocument } from '../model/keyword'; import { IMemeCreatePayload, IMemeUpdatePayload } from '../model/meme'; import { InteractionType } from '../model/memeInteraction'; +import { IUserDocument } from '../model/user'; +import { getKeywordByName } from '../service/keyword.service'; import * as MemeService from '../service/meme.service'; import * as UserService from '../service/user.service'; import { logger } from '../util/logger'; @@ -24,7 +27,7 @@ const getMeme = async (req: Request, res: Response, next: NextFunction) => { } if (!mongoose.Types.ObjectId.isValid(memeId)) { - return next(new CustomError(`'memeId' is not a valid ObjectId`, HttpCode.BAD_REQUEST)); + return next(new CustomError(`${memeId} is not a valid ObjectId`, HttpCode.BAD_REQUEST)); } try { @@ -166,9 +169,8 @@ const getTodayMemeList = async (req: CustomRequest, res: Response, next: NextFun } }; -const searchMemeListByKeyword = async (req: CustomRequest, res: Response, next: NextFunction) => { +const searchMemeList = async (req: CustomRequest, res: Response, next: NextFunction) => { const user = req.requestedUser; - const keyword = req.requestedKeyword; const page = parseInt(req.query.page as string) || 1; if (page < 1) { @@ -180,6 +182,37 @@ const searchMemeListByKeyword = async (req: CustomRequest, res: Response, next: return next(new CustomError(`Invalid 'size' parameter`, HttpCode.BAD_REQUEST)); } + const searchTerm = (req.query.q as string) || ''; + try { + if (searchTerm) { + // 검색어로 검색하는 경우 (query-parameter) + logger.info(`Search by searchTerm: ${searchTerm}`); + const result = await searchMemeListBySearchTerm(page, size, searchTerm, user); + return res.json(createSuccessResponse(HttpCode.OK, 'Search meme list by searchTerm', result)); + } else { + // 키워드로 검색하는 경우 (parameter) + const keyword = req.params?.name; + logger.info(`Search by keyword: ${keyword}`); + const requestedKeyword = await getKeywordByName(keyword); + if (_.isNull(requestedKeyword)) { + return next( + new CustomError(`Keyword name '${keyword}' does not exist`, HttpCode.NOT_FOUND), + ); + } + const result = await searchMemeListByKeyword(page, size, requestedKeyword, user); + return res.json(createSuccessResponse(HttpCode.OK, 'Search meme list by keyword', result)); + } + } catch (err) { + return next(new CustomError(err.message, err.status)); + } +}; + +const searchMemeListByKeyword = async ( + page: number, + size: number, + keyword: IKeywordDocument, + user: IUserDocument, +) => { try { const memeList = await MemeService.searchMemeByKeyword(page, size, keyword, user); const data = { @@ -193,21 +226,48 @@ const searchMemeListByKeyword = async (req: CustomRequest, res: Response, next: memeList: memeList.data, }; - return res.json(createSuccessResponse(HttpCode.OK, 'Search meme list by keyword', data)); + return data; } catch (err) { - return next(new CustomError(err.message, err.status)); + throw new CustomError(err.message, err.status); + } +}; + +const searchMemeListBySearchTerm = async ( + page: number, + size: number, + saerchTerm: string, + user: IUserDocument, +) => { + try { + const memeList = await MemeService.searchMemeBySearchTerm(page, size, saerchTerm, user); + const data = { + pagination: { + total: memeList.total, + page: memeList.page, + perPage: size, + currentPage: memeList.page, + totalPages: memeList.totalPages, + }, + memeList: memeList.data, + }; + + return data; + } catch (err) { + throw new CustomError(err.message, err.status); } }; const createMemeReaction = async (req: CustomRequest, res: Response, next: NextFunction) => { const user = req.requestedUser; const meme = req.requestedMeme; + const { count = 1 } = req.body; try { const result: boolean = await MemeService.createMemeInteraction( user, meme, InteractionType.REACTION, + count, ); return res.json(createSuccessResponse(HttpCode.CREATED, 'Create Meme Reaction', result)); } catch (err) { @@ -304,5 +364,7 @@ export { deleteMemeSave, updateMeme, getMemeWithKeywords, + searchMemeList, searchMemeListByKeyword, + searchMemeListBySearchTerm, }; diff --git a/src/middleware/requestedInfo.ts b/src/middleware/requestedInfo.ts index 9b776e5..a7342ef 100644 --- a/src/middleware/requestedInfo.ts +++ b/src/middleware/requestedInfo.ts @@ -35,7 +35,7 @@ export const getRequestedMemeInfo = async ( } if (!mongoose.Types.ObjectId.isValid(memeId)) { - return next(new CustomError(`'memeId' is not a valid ObjectId`, HttpCode.BAD_REQUEST)); + return next(new CustomError(`${memeId} is not a valid ObjectId`, HttpCode.BAD_REQUEST)); } const meme: IMemeDocument = await getMeme(memeId); diff --git a/src/model/meme.ts b/src/model/meme.ts index cae2983..c8799ba 100644 --- a/src/model/meme.ts +++ b/src/model/meme.ts @@ -59,7 +59,7 @@ const MemeSchema: Schema = new Schema( keywordIds: { type: [Types.ObjectId], ref: 'Keyword', required: true, default: [] }, image: { type: String, required: true }, reaction: { type: Number, required: true, default: 0 }, - source: { type: String, required: true }, + source: { type: String, required: true, default: '' }, isTodayMeme: { type: Boolean, requried: true, default: false }, isDeleted: { type: Boolean, required: true, default: false }, }, diff --git a/src/routes/meme.ts b/src/routes/meme.ts index 008f95b..ac2222c 100644 --- a/src/routes/meme.ts +++ b/src/routes/meme.ts @@ -11,12 +11,11 @@ import { createMemeSave, createMemeReaction, createMemeWatch, - searchMemeListByKeyword, deleteMemeSave, + searchMemeList, } from '../controller/meme.controller'; import { getRequestedMemeInfo, - getKeywordInfoByName, getRequestedUserInfo, getRequestedMemeSaveInfo, } from '../middleware/requestedInfo'; @@ -302,6 +301,312 @@ router.get('/list', getRequestedUserInfo, getAllMemeList); // meme 목록 전체 */ router.get('/recommend-memes', getRequestedUserInfo, getTodayMemeList); // 오늘의 추천 밈 (5개) +/** + * @swagger + * /api/meme/search?q={term}: + * get: + * tags: [Meme] + * summary: 검색어로 밈을 검색한다. (페이지네이션 적용) + * description: 사용자가 검색어를 입력하면 해당 검색어가 제목(title), 출처(source)에 포함된 밈을 조회하고 목록을 반환한다. 이때 리액션(reaction) 많은 순으로 정렬되며, 페이지네이션이 적용된다. + * parameters: + * - name: x-device-id + * in: header + * description: 유저의 고유한 deviceId + * required: true + * type: string + * - in: query + * name: q + * schema: + * type: string + * example: "무한" + * required: true + * description: 검색어 + * - in: query + * name: page + * schema: + * type: number + * example: 1 + * description: 현재 페이지 번호 (기본값 1) + * - in: query + * name: size + * schema: + * type: number + * example: 10 + * description: 한 번에 조회할 밈 개수 (기본값 10) + * responses: + * 200: + * description: 검색된 밈 목록 + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * code: + * type: integer + * example: 200 + * message: + * type: string + * example: Search meme by keyword + * data: + * type: object + * properties: + * pagination: + * type: object + * properties: + * total: + * type: integer + * example: 15 + * page: + * type: integer + * example: 1 + * perPage: + * type: integer + * example: 10 + * currentPage: + * type: integer + * example: 1 + * totalPages: + * type: integer + * example: 2 + * memeList: + * type: array + * items: + * type: object + * properties: + * _id: + * type: string + * example: "66805b1a72ef94c9c0ba134c" + * image: + * type: string + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" + * isTodayMeme: + * type: boolean + * example: false + * isSaved: + * type: boolean + * example: true + * isReaction: + * type: boolean + * example: true + * keywords: + * type: array + * items: + * type: object + * properties: + * _id: + * type: string + * example: "66805b1a72ef94c9c0ba134c" + * name: + * type: string + * example: "무한도전" + * title: + * type: string + * example: "무한상사 정총무" + * source: + * type: string + * example: "무한도전 102화" + * reaction: + * type: integer + * example: 99 + * description: 밈 리액션 수 + * watch: + * type: integer + * example: 999 + * description: 밈 조회수 + * createdAt: + * type: string + * format: date-time + * example: "2024-06-29T19:06:02.489Z" + * updatedAt: + * type: string + * format: date-time + * example: "2024-06-29T19:06:02.489Z" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: error + * code: + * type: integer + * example: 500 + * message: + * type: string + * example: Internal server error + * data: + * type: null + * example: null + */ +router.get('/search', getRequestedUserInfo, searchMemeList); // 밈 검색 (with 검색어) + +/** + * @swagger + * /api/meme/search/{name}: + * get: + * tags: [Meme] + * summary: 특정 키워드가 포함된 밈 검색 (페이지네이션 적용) + * description: 키워드 클릭 시 해당 키워드를 포함한 밈을 조회하고 목록을 반환한다. 키워드가 완벽하게 일치해야한다. + * parameters: + * - name: x-device-id + * in: header + * description: 유저의 고유한 deviceId + * required: true + * type: string + * - in: query + * name: page + * schema: + * type: number + * example: 1 + * description: 현재 페이지 번호 (기본값 1) + * - in: query + * name: size + * schema: + * type: number + * example: 10 + * description: 한 번에 조회할 밈 개수 (기본값 10) + * - in: path + * name: name + * schema: + * type: string + * example: "무한도전" + * required: true + * description: 키워드명 + * responses: + * 200: + * description: 키워드를 포함한 밈 목록 + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * code: + * type: integer + * example: 200 + * message: + * type: string + * example: Search meme by keyword + * data: + * type: object + * properties: + * pagination: + * type: object + * properties: + * total: + * type: integer + * example: 2 + * page: + * type: integer + * example: 1 + * perPage: + * type: integer + * example: 10 + * currentPage: + * type: integer + * example: 1 + * totalPages: + * type: integer + * example: 1 + * memeList: + * type: array + * items: + * type: object + * properties: + * _id: + * type: string + * example: "66805b1a72ef94c9c0ba134c" + * image: + * type: string + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" + * isTodayMeme: + * type: boolean + * example: false + * isSaved: + * type: boolean + * example: true + * isReaction: + * type: boolean + * example: true + * keywords: + * type: array + * items: + * type: object + * properties: + * _id: + * type: string + * example: "66805b1a72ef94c9c0ba134c" + * name: + * type: string + * example: "행복" + * title: + * type: string + * example: "무한상사 정총무" + * source: + * type: string + * example: "무한도전 102화" + * reaction: + * type: integer + * example: 99 + * description: 밈 리액션 수 + * watch: + * type: integer + * example: 999 + * description: 밈 조회수 + * createdAt: + * type: string + * format: date-time + * example: "2024-06-29T19:06:02.489Z" + * updatedAt: + * type: string + * format: date-time + * example: "2024-06-29T19:06:02.489Z" + * 400: + * description: Invalid keyword name + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: error + * code: + * type: integer + * example: 400 + * message: + * type: string + * example: Keyword with name '행복' does not exist + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: error + * code: + * type: integer + * example: 500 + * message: + * type: string + * example: Internal server error + * data: + * type: null + * example: null + */ +router.get('/search/:name', getRequestedUserInfo, searchMemeList); // 밈 검색 (with 키워드) + /** * @swagger * /api/meme: @@ -1265,6 +1570,16 @@ router.post('/:memeId/watch/:type', getRequestedUserInfo, getRequestedMemeInfo, * type: string * required: true * description: 리액션할 밈 id + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * count: + * type: number + * example: 1 * responses: * 201: * description: Created Meme Reaction @@ -1342,165 +1657,4 @@ router.post('/:memeId/watch/:type', getRequestedUserInfo, getRequestedMemeInfo, */ router.post('/:memeId/reaction', getRequestedUserInfo, getRequestedMemeInfo, createMemeReaction); // meme 리액션 남기기 -/** - * @swagger - * /api/meme/search/{name}: - * get: - * tags: [Meme] - * summary: 키워드가 포함된 밈 검색 (페이지네이션 적용) - * description: 키워드 클릭 시 해당 키워드를 포함한 밈을 조회하고 목록을 반환한다. - * parameters: - * - name: x-device-id - * in: header - * description: 유저의 고유한 deviceId - * required: true - * type: string - * - in: query - * name: page - * schema: - * type: number - * example: 1 - * description: 현재 페이지 번호 (기본값 1) - * - in: query - * name: size - * schema: - * type: number - * example: 10 - * description: 한 번에 조회할 밈 개수 (기본값 10) - * - in: path - * name: name - * schema: - * type: string - * example: "행복" - * required: true - * description: 키워드명 - * responses: - * 200: - * description: 키워드를 포함한 밈 목록 - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: success - * code: - * type: integer - * example: 200 - * message: - * type: string - * example: Search meme by keyword - * data: - * type: object - * properties: - * pagination: - * type: object - * properties: - * total: - * type: integer - * example: 2 - * page: - * type: integer - * example: 1 - * perPage: - * type: integer - * example: 10 - * currentPage: - * type: integer - * example: 1 - * totalPages: - * type: integer - * example: 1 - * memeList: - * type: array - * items: - * type: object - * properties: - * _id: - * type: string - * example: "66805b1a72ef94c9c0ba134c" - * image: - * type: string - * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" - * isTodayMeme: - * type: boolean - * example: false - * isSaved: - * type: boolean - * example: true - * isReaction: - * type: boolean - * example: true - * keywords: - * type: array - * items: - * type: object - * properties: - * _id: - * type: string - * example: "66805b1a72ef94c9c0ba134c" - * name: - * type: string - * example: "행복" - * title: - * type: string - * example: "무한상사 정총무" - * source: - * type: string - * example: "무한도전 102화" - * reaction: - * type: integer - * example: 99 - * description: 밈 리액션 수 - * watch: - * type: integer - * example: 999 - * description: 밈 조회수 - * createdAt: - * type: string - * format: date-time - * example: "2024-06-29T19:06:02.489Z" - * updatedAt: - * type: string - * format: date-time - * example: "2024-06-29T19:06:02.489Z" - * 400: - * description: Invalid keyword name - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: error - * code: - * type: integer - * example: 400 - * message: - * type: string - * example: Keyword with name '행복' does not exist - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: error - * code: - * type: integer - * example: 500 - * message: - * type: string - * example: Internal server error - * data: - * type: null - * example: null - */ -router.get('/search/:name', getRequestedUserInfo, getKeywordInfoByName, searchMemeListByKeyword); // 키워드에 해당하는 밈 검색하기 (페이지네이션) - export default router; diff --git a/src/service/keyword.service.ts b/src/service/keyword.service.ts index d2eca81..3155f9b 100644 --- a/src/service/keyword.service.ts +++ b/src/service/keyword.service.ts @@ -25,8 +25,10 @@ async function createKeyword(info: IKeywordCreatePayload): Promise { const deletedKeyword = await KeywordModel.findOneAndDelete({ _id: keywordId }).lean(); if (_.isNull(deletedKeyword)) { - throw new CustomError(`Keyword with ID ${keywordId} not found`, HttpCode.NOT_FOUND); + throw new CustomError(`Keyword(${keywordId}) not found`, HttpCode.NOT_FOUND); } return true; } @@ -63,8 +66,10 @@ async function getTopKeywords(limit: number = 6): Promise { .lean(); return topKeywords; } catch (err) { - logger.error(`Failed to get top keywords: ${err.message}`); - throw new CustomError('Failed to get top keywords', HttpCode.INTERNAL_SERVER_ERROR); + throw new CustomError( + `Failed to get top keywords: ${err.message}`, + HttpCode.INTERNAL_SERVER_ERROR, + ); } } @@ -76,12 +81,14 @@ async function increaseSearchCount(keywordId: Types.ObjectId): Promise { + try { + const searchedKeywords = await KeywordModel.find({ + name: { $regex: term, $options: 'i' }, + }); + + const keywordIds: Types.ObjectId[] = searchedKeywords.map((keyword) => keyword._id); + logger.info(`Successfully searched keywords with term ${term}`); + return keywordIds; + } catch (err) { + logger.error(`Failed to search keywords with term '${term}' - ${err.message}`); + throw new CustomError( + `Failed to search keywords with term '${term}'`, + HttpCode.INTERNAL_SERVER_ERROR, + ); } } @@ -187,4 +211,5 @@ export { getKeywordById, getRecommendedKeywords, getKeywordInfoByKeywordIds, + getSearchedKeywords, }; diff --git a/src/service/keywordCategory.service.ts b/src/service/keywordCategory.service.ts index ad80d0c..8a126df 100644 --- a/src/service/keywordCategory.service.ts +++ b/src/service/keywordCategory.service.ts @@ -22,8 +22,10 @@ async function createKeywordCategory( logger.info(`Created new keyword category: ${JSON.stringify(newCategoryObj)}`); return newCategoryObj; } catch (err) { - logger.error(`Failed to create category ${info.name}: ${err.message}`); - throw new CustomError('Failed to create category', HttpCode.INTERNAL_SERVER_ERROR); + throw new CustomError( + `Failed to create category ${info.name}: ${err.message}`, + HttpCode.INTERNAL_SERVER_ERROR, + ); } } @@ -55,6 +57,7 @@ async function deleteKeywordCategory(categoryName: string): Promise { if (_.isNull(deletedCategory)) { throw new CustomError(`Category with Name ${categoryName} not found`, HttpCode.NOT_FOUND); } + return true; } diff --git a/src/service/meme.service.ts b/src/service/meme.service.ts index 10ae218..edcdb37 100644 --- a/src/service/meme.service.ts +++ b/src/service/meme.service.ts @@ -19,8 +19,10 @@ async function getMeme(memeId: string): Promise { return meme || null; } catch (err) { - logger.error(`Failed to get a meme(${memeId}): ${err.message}`); - throw new CustomError(`Failed to get a meme(${memeId})`, HttpCode.INTERNAL_SERVER_ERROR); + throw new CustomError( + `Failed to get a meme(${memeId}): ${err.message}`, + HttpCode.INTERNAL_SERVER_ERROR, + ); } } @@ -51,9 +53,8 @@ async function getMemeWithKeywords( isReaction: !_.isNil(isReaction), }; } catch (err) { - logger.error(`Failed to get a meme(${meme._id}) with keywords: ${err.message}`); throw new CustomError( - `Failed to get a meme(${meme._id}) with keywords`, + `Failed to get a meme(${meme._id}) with keywords: ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -78,9 +79,8 @@ async function getTodayMemeList( return memeList; } catch (err) { - logger.error(`Failed to get today meme list: ${err.message}`); throw new CustomError( - `Failed to get today meme list ${err.message}`, + `Failed to get today meme list: ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -141,9 +141,8 @@ async function getMemeListWithKeywordsAndisSavedAndisReaction( }), ); } catch (err) { - logger.error('Failed to get keywords and isSaved info from meme list', err.message); throw new CustomError( - `Failed to get keywords and isSaved info from meme list ${err.message}`, + `Failed to get keywords and isSaved info from meme list: ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -231,9 +230,61 @@ async function searchMemeByKeyword( data: memeList, }; } catch (err) { - logger.error(`Failed to search meme list with keyword(${keyword})`, err.message); throw new CustomError( - `Failed to search meme list with keyword(${keyword})`, + `Failed to search meme list with keyword(${keyword}): ${err.message}`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } +} + +async function searchMemeBySearchTerm( + page: number, + size: number, + searchTerm: string, + user: IUserDocument, +): Promise<{ total: number; page: number; totalPages: number; data: IMemeGetResponse[] }> { + try { + // 'searchTerm'으로 키워드 우선 검색 + const keywordIds = await KeywordService.getSearchedKeywords(searchTerm); + + // 검색 범위: 제목(title) / 출처(source / 키워드명(keyword.name) + const searchCondition = { + $or: [ + { title: { $regex: searchTerm, $options: 'i' } }, + { source: { $regex: searchTerm, $options: 'i' } }, + { keywordIds: { $in: keywordIds } }, + ], + isDeleted: false, + }; + + const [totalMemeCount, searchResult] = await Promise.all([ + MemeModel.countDocuments(searchCondition), + MemeModel.find(searchCondition) + .skip((page - 1) * size) + .limit(size) + .sort({ reaction: -1, _id: 1 }) + .sort({ reaction: -1 }), + ]); + + logger.info( + `Search Meme(term: ${searchTerm}) - page(${page}), size(${size}), total(${totalMemeCount})`, + ); + + const memeList = + totalMemeCount > 0 + ? await getMemeListWithKeywordsAndisSavedAndisReaction(user, searchResult) + : []; + + return { + total: totalMemeCount, + page, + totalPages: Math.ceil(totalMemeCount / size), + data: memeList, + }; + } catch (err) { + logger.error(`Failed to search meme list with searchTerm(${searchTerm})`, err.message); + throw new CustomError( + `Failed to search meme list with searchTerm(${searchTerm})`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -243,6 +294,7 @@ async function createMemeInteraction( user: IUserDocument, meme: IMemeDocument, interactionType: InteractionType, + count: number = 1, ): Promise { try { // 'save' interaction은 isDeleted 조건 검색 필요없음 @@ -265,14 +317,12 @@ async function createMemeInteraction( ); // interactionType에 따른 동작 처리 (MemeInteracionService에서 진행) - await MemeInteractionService.updateMemeInteraction(user, meme, interactionType); + await MemeInteractionService.updateMemeInteraction(user, meme, interactionType, count); } return true; } catch (err) { - logger.error(`Failed to create memeInteraction(${interactionType})`, err.message); - throw new CustomError( - `Failed to create memeInteraction(${interactionType}) (${err.message})`, + `Failed to create memeInteraction(${interactionType}): ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -283,9 +333,8 @@ async function deleteMemeSave(user: IUserDocument, meme: IMemeDocument): Promise await MemeInteractionService.deleteMemeInteraction(user, meme, InteractionType.SAVE); return true; } catch (err) { - logger.error(`Failed to delete meme save`, err.message); throw new CustomError( - `Failed to delete meme save(${err.message})`, + `Failed to delete meme save: ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -307,9 +356,8 @@ async function getTopReactionImage(keyword: IKeywordDocument): Promise { logger.info(`Get top reaction meme - keyword(${keyword.name}), meme(${topReactionMeme._id})`); return topReactionMeme.image; } catch (err) { - logger.error(`Failed get top reaction meme image`, err.message); throw new CustomError( - `Failed get top reaction meme(${err.message})`, + `Failed get top reaction meme image: ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -328,5 +376,6 @@ export { deleteKeywordOfMeme, getMemeWithKeywords, searchMemeByKeyword, + searchMemeBySearchTerm, getTopReactionImage, }; diff --git a/src/service/memeInteraction.service.ts b/src/service/memeInteraction.service.ts index 2b9bad5..eb61d2e 100644 --- a/src/service/memeInteraction.service.ts +++ b/src/service/memeInteraction.service.ts @@ -32,12 +32,9 @@ async function getMemeInteractionInfo( }); return memeInteraction || null; - } catch (error) { - logger.error(`Failed to get a MemeInteraction Info(${meme._id} - ${interactionType})`, { - error, - }); + } catch (err) { throw new CustomError( - `Failed to get a MemeInteraction Info(${meme._id} - ${interactionType})`, + `Failed to get a MemeInteraction info(${meme._id} - ${interactionType}): ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -60,9 +57,8 @@ async function getMemeInteractionInfoWithCondition( const memeInteraction = await MemeInteractionModel.findOne(condition); return memeInteraction || null; } catch (err) { - logger.error(`Failed to get a MemeInteraction Info(${meme._id} - ${interactionType})`); throw new CustomError( - `Failed to get a MemeInteraction Info(${meme._id} - ${interactionType})`, + `Failed to get a MemeInteraction Info(${meme._id} - ${interactionType}): ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -80,9 +76,8 @@ async function getMemeInteractionCount( }); return count; } catch (err) { - logger.error(`Failed to count MemeInteraction(${interactionType})`); throw new CustomError( - `Failed to count MemeInteraction(${interactionType}) (${err.message})`, + `Failed to count MemeInteraction(${interactionType}): ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -109,9 +104,8 @@ async function getMemeInteractionList( return memeInteractionList; } catch (err) { - logger.error(`Failed to count MemeInteraction(${interactionType})`); throw new CustomError( - `Failed to count MemeInteraction(${interactionType}) (${err.message})`, + `Failed to count MemeInteraction(${interactionType}): ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -131,9 +125,8 @@ async function createMemeInteraction( await newMemeInteraction.save(); return newMemeInteraction; } catch (err) { - logger.error(`Failed to create a MemeInteraction(${meme._id} - ${interactionType})`); throw new CustomError( - `Failed to create a MemeInteraction(${meme._id} - ${interactionType})`, + `Failed to create a MemeInteraction(${meme._id} - ${interactionType}): ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -143,6 +136,7 @@ async function updateMemeInteraction( user: IUserDocument, meme: IMemeDocument, interactionType: InteractionType, + count: number = 1, ): Promise { switch (interactionType) { // 'save' isDeleted false로 변경 @@ -159,7 +153,7 @@ async function updateMemeInteraction( case InteractionType.REACTION: await MemeModel.findOneAndUpdate( { _id: meme._id, isDeleted: false }, - { $inc: { reaction: 1 } }, + { $inc: { reaction: count } }, { projection: { _id: 0, createdAt: 0, updatedAt: 0 }, returnDocument: 'after', @@ -175,7 +169,6 @@ async function updateMemeInteraction( break; default: - logger.error(`Unsupported interactionType(${interactionType})`); throw new CustomError( `Unsupported interactionType(${interactionType})`, HttpCode.BAD_REQUEST, @@ -198,9 +191,8 @@ async function deleteMemeInteraction( return memeInteraction; } catch (err) { - logger.error(`Failed to delete a MemeInteraction(${meme._id} - ${interactionType})`); throw new CustomError( - `Failed to delete a MemeInteraction(${meme._id} - ${interactionType})`, + `Failed to delete a MemeInteraction(${meme._id} - ${interactionType}): ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } diff --git a/src/service/user.service.ts b/src/service/user.service.ts index 8534b7c..a6c7f54 100644 --- a/src/service/user.service.ts +++ b/src/service/user.service.ts @@ -26,9 +26,8 @@ async function getUser(deviceId: string): Promise { ); return user?.toObject() || null; } catch (err) { - logger.error(`Failed to getUser - deviceId${deviceId}`); throw new CustomError( - 'Failed to getUser - deviceId${deviceId}`', + `Failed to getUser - deviceId(${deviceId}): ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -88,7 +87,6 @@ async function createUser(deviceId: string): Promise { logger.info(`Created user - deviceId(${JSON.stringify(user.toObject())})`); return { ...user.toObject(), watch: 0, share: 0, reaction: 0, save: 0 }; } catch (err) { - logger.error(`Failed to create User`); throw new CustomError(`Failed to create a User`, HttpCode.INTERNAL_SERVER_ERROR); } } @@ -126,9 +124,8 @@ async function updateLastSeenMeme(user: IUserDocument, meme: IMemeDocument): Pro ); return updatedUser; } catch (err) { - logger.error(`Failed Update user lastSeenMeme`, err.message); throw new CustomError( - `Failed Update user lastSeenMeme(${err.message})`, + `Failed Update user lastSeenMeme: ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -161,9 +158,8 @@ async function getLastSeenMemeList(user: IUserDocument): Promise memeMap[id.toString()]); return sortedGetLastSeenMemeList; } catch (err) { - logger.error(`Failed get lastSeenMemeList`, err.message); throw new CustomError( - `Failed get lastSeenMemeList(${err.message})`, + `Failed to get lastSeenMemeList: ${err.message}`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -205,8 +201,8 @@ async function getSavedMemeList( totalPages: Math.ceil(totalSavedMemes / size), data: savedMemeList, }; - } catch (error) { - throw new CustomError(`Failed to get saved memes`, HttpCode.INTERNAL_SERVER_ERROR, error); + } catch (err) { + throw new CustomError(`Failed to get saved memes`, HttpCode.INTERNAL_SERVER_ERROR, err); } } @@ -248,9 +244,8 @@ async function createMemeRecommendWatch(user: IUserDocument, meme: IMemeDocument return memeRecommendWatchCount; } catch (err) { - logger.error(`Failed create memeRecommendWatch`, err.message); throw new CustomError( - `Failed create memeRecommendWatch(${err.message})`, + `Failed to create memeRecommendWatch(${err.message})`, HttpCode.INTERNAL_SERVER_ERROR, ); } diff --git a/src/util/config.ts b/src/util/config.ts index b87ffd4..4f56b11 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -19,7 +19,10 @@ const FCM_PROJECT_ID = process.env.FCM_PROJECT_ID; const FCM_PRIVATE_KEY = process.env.FCM_PRIVATE_KEY; const FCM_CLIENT_EMAIL = process.env.FCM_CLIENT_EMAIL; +const ENV = process.env.NODE_ENV; + export default { + ENV, DB_URL, PORT, AWS_ACCESS_KEY_ID, diff --git a/src/util/logger.ts b/src/util/logger.ts index 98424b4..ce73c54 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -3,6 +3,8 @@ import { randomUUID } from 'crypto'; import { Request, Response, NextFunction } from 'express'; import pino from 'pino'; +import config from './config'; + const transport = pino.transport({ targets: [ { @@ -20,24 +22,35 @@ const attachRequestId = (req: Request, res: Response, next: NextFunction) => { next(); }; +const ENV = `${config.ENV}`; +const isProduction = ENV === 'production'; +logger.info(`env: ${ENV} - ${isProduction}`); + const loggerMiddleware = (req: Request, res: Response, next: NextFunction) => { - const start = Date.now(); + const startTime = Date.now(); + const { method, originalUrl, body } = req; logger.info( - { method: req.method, url: req.originalUrl, body: req.body }, - `--> [${req.method}] ${req.originalUrl} Request called`, + isProduction ? undefined : { method, url: originalUrl, body }, + `--> ${method} ${originalUrl}`, ); const originalSend = res.send; res.send = (data) => { - logger[res.statusCode !== 200 ? 'error' : 'info']( - { - statusCode: res.statusCode, - duration: `${Date.now() - start}ms`, - response: JSON.parse(data), - }, - `<-- [${req.method}] ${res.statusCode} ${req.originalUrl} Response received in ${Date.now() - start}ms`, + const duration = Date.now() - startTime; + const statusCode = res.statusCode; + + logger[statusCode !== 200 ? 'error' : 'info']( + isProduction + ? undefined + : { + method, + url: originalUrl, + response: JSON.parse(data), + }, + `<-- ${statusCode} ${method} ${originalUrl} (${duration}ms)`, ); + return originalSend.bind(res)(data); }; diff --git a/test/meme/search-meme-by-keyword.test.ts b/test/meme/search-meme-by-keyword.test.ts new file mode 100644 index 0000000..cfb5fb8 --- /dev/null +++ b/test/meme/search-meme-by-keyword.test.ts @@ -0,0 +1,117 @@ +import request from 'supertest'; + +import app from '../../src/app'; +import { KeywordModel } from '../../src/model/keyword'; +import { MemeModel } from '../../src/model/meme'; +import { UserModel } from '../../src/model/user'; +import { keywordMockData } from '../util/keyword.search.mock'; +import { createMockData as createMemeMockData } from '../util/meme.mock'; +import { mockUser } from '../util/user.mock'; + +// 검색 - 키워드 +describe("[GET] '/api/meme/search/:name' ", () => { + beforeAll(async () => { + const createdKeywords = await KeywordModel.insertMany(keywordMockData); + + let keywordIds = []; + keywordIds = createdKeywords.map((k) => k._id); + // 각 키워드마다 밈을 5개씩 생성 + + keywordIds.map(async (k) => { + await MemeModel.insertMany(createMemeMockData(5, 0, [k])); + }); + + await UserModel.insertMany(mockUser); + }); + + afterAll(async () => { + await MemeModel.deleteMany({}); + await UserModel.deleteMany({}); + }); + + it(`should return searched meme list by keyword - 분노`, async () => { + const response = await request(app).get(`/api/meme/search/분노`).set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.pagination.total).toBe(5); + expect(response.body.data.pagination.page).toBe(1); + expect(response.body.data.pagination.totalPages).toBe(1); + expect(response.body.data.memeList.length).toBe(5); + + const memeList = response.body.data.memeList; + expect(memeList[0].keywords).toContainEqual({ _id: expect.any(String), name: '분노' }); // 해당 keyword를 가지고 있는지 검증 + expect(memeList[0]).toHaveProperty('image'); + expect(memeList[0]).toHaveProperty('source'); + expect(memeList[0]).toHaveProperty('isTodayMeme'); + expect(memeList[0]).toHaveProperty('reaction'); + expect(memeList[1].reaction).toBeLessThanOrEqual(memeList[0].reaction); // reaction 내림차순 정렬 검증 + }); + + it(`should return searched meme list by keyword - 고민`, async () => { + const response = await request(app).get(`/api/meme/search/고민`).set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.pagination.total).toBe(5); + expect(response.body.data.pagination.page).toBe(1); + expect(response.body.data.pagination.totalPages).toBe(1); + expect(response.body.data.memeList.length).toBe(5); + + const memeList = response.body.data.memeList; + expect(memeList[0].keywords).toContainEqual({ _id: expect.any(String), name: '고민' }); // 해당 keyword를 가지고 있는지 검증 + expect(memeList[0]).toHaveProperty('image'); + expect(memeList[0]).toHaveProperty('source'); + expect(memeList[0]).toHaveProperty('isTodayMeme'); + expect(memeList[0]).toHaveProperty('reaction'); + expect(memeList[1].reaction).toBeLessThanOrEqual(memeList[0].reaction); // reaction 내림차순 정렬 검증 + }); + + it(`should return paginated list of memes for specific page and size`, async () => { + const size = 2; + const page = 1; + const response = await request(app) + .get(`/api/meme/search/고민?page=${page}&size=${size}`) + .set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.pagination.total).toBe(5); + expect(response.body.data.pagination.page).toBe(1); + expect(response.body.data.pagination.totalPages).toBe(3); + expect(response.body.data.memeList.length).toBe(size); + + const memeList = response.body.data.memeList; + expect(memeList[0].keywords).toContainEqual({ _id: expect.any(String), name: '고민' }); // 해당 keyword를 가지고 있는지 검증 + expect(memeList[0]).toHaveProperty('image'); + expect(memeList[0]).toHaveProperty('source'); + expect(memeList[0]).toHaveProperty('isTodayMeme'); + expect(memeList[0]).toHaveProperty('reaction'); + expect(memeList[1].reaction).toBeLessThanOrEqual(memeList[0].reaction); // reaction 내림차순 정렬 검증 + }); + + it(`should return an error for invalid keyword - 존재하지않는키워드`, async () => { + const response = await request(app) + .get(`/api/meme/search/존재하지않는키워드`) + .set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(404); + }); + + it('should return an error for invalid page', async () => { + const size = 5; + const page = -1; + const response = await request(app) + .get(`/api/meme/search/다이어트?page=${page}&size=${size}`) + .set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(400); + }); + + it('should return an error for invalid size', async () => { + const size = -1; + const page = 3; + const response = await request(app) + .get(`/api/meme/search/다이어트?page=${page}&size=${size}`) + .set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(400); + }); +}); diff --git a/test/meme/search-meme-by-term.test.ts b/test/meme/search-meme-by-term.test.ts new file mode 100644 index 0000000..f627916 --- /dev/null +++ b/test/meme/search-meme-by-term.test.ts @@ -0,0 +1,106 @@ +import request from 'supertest'; + +import app from '../../src/app'; +import { KeywordModel } from '../../src/model/keyword'; +import { MemeModel } from '../../src/model/meme'; +import { UserModel } from '../../src/model/user'; +import { createMockData as createKeywordMockData } from '../util/keyword.mock'; +import { createMemeSearchMockData } from '../util/meme.search.mock'; +import { mockUser } from '../util/user.mock'; + +let keywordIds = []; +let keywords = []; + +// 검색 - 검색어 +describe("[GET] '/api/meme/search?q={term}' ", () => { + beforeAll(async () => { + // 현재 keyword를 검색에 직접적으로 사용하지 않으므로 mock data 만들기 용으로만 사용한다. + const keywordMockDatas = createKeywordMockData(5); + const createdKeywords = await KeywordModel.insertMany(keywordMockDatas); + keywordIds = createdKeywords.map((k) => k._id); + keywords = createdKeywords.map((k) => k.name); + + const memeMockDatas = createMemeSearchMockData(keywordIds); + await MemeModel.insertMany(memeMockDatas); + await UserModel.insertMany(mockUser); + }); + + afterAll(async () => { + await MemeModel.deleteMany({}); + await UserModel.deleteMany({}); + }); + + it(`should return searched meme list - 야근`, async () => { + const response = await request(app) + .get(`/api/meme/search?q=야근`) + .set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.pagination.total).toBe(2); + expect(response.body.data.pagination.page).toBe(1); + expect(response.body.data.pagination.totalPages).toBe(1); + expect(response.body.data.memeList.length).toBe(2); + + const memeList = response.body.data.memeList; + expect(memeList[0]).toHaveProperty('keywordIds'); + expect(memeList[0]).toHaveProperty('image'); + expect(memeList[0]).toHaveProperty('source'); + expect(memeList[0]).toHaveProperty('isTodayMeme'); + expect(memeList[0]).toHaveProperty('reaction'); + expect(memeList[1].reaction).toBeLessThanOrEqual(memeList[0].reaction); // reaction 내림차순 정렬 확인 + }); + + it(`should return searched meme list - 학교`, async () => { + const response = await request(app) + .get(`/api/meme/search?q=학교`) + .set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.pagination.total).toBe(3); + expect(response.body.data.pagination.page).toBe(1); + expect(response.body.data.pagination.totalPages).toBe(1); + expect(response.body.data.memeList.length).toBe(3); + + const memeList = response.body.data.memeList; + expect(memeList[0]).toHaveProperty('keywordIds'); + expect(memeList[0]).toHaveProperty('image'); + expect(memeList[0]).toHaveProperty('source'); + expect(memeList[0]).toHaveProperty('isTodayMeme'); + expect(memeList[0]).toHaveProperty('reaction'); + expect(memeList[1].reaction).toBeLessThanOrEqual(memeList[0].reaction); // reaction 내림차순 정렬 확인 + }); + + it('should return paginated list of memes for specific page and size', async () => { + const size = 2; + const page = 1; + const response = await request(app) + .get(`/api/meme/search?q=학교&page=${page}&size=${size}`) + .set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.pagination.total).toBe(3); + expect(response.body.data.pagination.page).toBe(page); + expect(response.body.data.pagination.totalPages).toBe(2); + expect(response.body.data.memeList.length).toBe(size); + }); + + it('should return an error for invalid page', async () => { + const size = 5; + const page = -1; + const response = await request(app) + .get(`/api/meme/search?q=무한&page=${page}&size=${size}`) + .set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(400); + }); + + it('should return an error for invalid size', async () => { + const size = -1; + const page = 3; + const response = await request(app) + .get(`/api/meme/search?q=무한&page=${page}&size=${size}`) + .set('x-device-id', 'deviceId'); + + expect(response.statusCode).toBe(400); + }); +}); diff --git a/test/util/keyword.search.mock.ts b/test/util/keyword.search.mock.ts new file mode 100644 index 0000000..9c05d1f --- /dev/null +++ b/test/util/keyword.search.mock.ts @@ -0,0 +1,12 @@ +import { IKeyword } from '../../src/model/keyword'; + +const keywordMockData: IKeyword[] = [ + { name: '분노', searchCount: 0, category: '감정' }, + { name: '슬픔', searchCount: 22, category: '감정' }, + { name: '동물', searchCount: 0, category: '콘텐츠' }, + { name: '문상훈', searchCount: 40, category: '콘텐츠' }, + { name: '다이어트', searchCount: 0, category: '상황' }, + { name: '고민', searchCount: 10, category: '상황' }, +]; + +export { keywordMockData }; diff --git a/test/util/meme.search.mock.ts b/test/util/meme.search.mock.ts new file mode 100644 index 0000000..1276be2 --- /dev/null +++ b/test/util/meme.search.mock.ts @@ -0,0 +1,57 @@ +import { Types } from 'mongoose'; + +import { IMeme } from '../../src/model/meme'; + +// 검색 기능 테스트 전용 mock +// !TODO: 필요시 마음대로 추가해도 됨 +// '야근', '학교' 로 검색했을 때 결과가 나올 수 있게 세팅한 mock +const memeMockData = [ + { + title: '오늘도 또 야근입니까?', + source: '무한도전', + keywordIds: [], + image: 'example.com', + isTodayMeme: false, + reaction: 0, + }, + { + title: '저 오늘부로 그냥 퇴사하겠습니다.', + source: '야근에 미쳐버린 직장인', + keywordIds: [], + image: 'example.com', + isTodayMeme: false, + reaction: 10, + }, + { + title: '학교안가', + source: '작자미상', + keywordIds: [], + image: 'example.com', + isTodayMeme: false, + reaction: 0, + }, + { + title: '절레절레', + source: '열혈초등학교', + keywordIds: [], + image: 'example.com', + isTodayMeme: false, + reaction: 22, + }, + { + title: '이제 그만 다니고 싶다. 학교...', + source: '대학일기', + keywordIds: [], + image: 'example.com', + isTodayMeme: false, + reaction: 22, + }, +]; + +const createMemeSearchMockData = (keywordIds: Types.ObjectId[] = []): IMeme[] => + memeMockData.map((meme) => ({ + ...meme, + keywordIds, + })); + +export { createMemeSearchMockData };