diff --git a/.github/workflows/backend-dev-cd.yml b/.github/workflows/backend-dev-cd.yml
index a535049b..60285971 100644
--- a/.github/workflows/backend-dev-cd.yml
+++ b/.github/workflows/backend-dev-cd.yml
@@ -66,7 +66,7 @@ jobs:
username: ${{ secrets.BACKEND_DEV_REMOTE_SSH_ID }}
password: ${{ secrets.BACKEND_DEV_REMOTE_ADMIN_KEY }}
port: ${{ secrets.BACKEND_DEV_REMOTE_SSH_PORT }}
- source: 'backend/docker-compose.yml,backend/nginx/dev/nginx.conf,backend/scripts/dev-deploy.sh'
+ source: 'backend/docker-compose.yml,backend/nginx/dev/conf/nginx.conf,backend/scripts/dev-deploy.sh'
target: 'moyeomoyeo'
- name: deploy
diff --git a/README.md b/README.md
index 89d4a54e..e4f796ef 100644
--- a/README.md
+++ b/README.md
@@ -39,11 +39,43 @@
+# ✨ 주요 기능
+
+[👉🏻 시연영상 보러가기](https://www.youtube.com/watch?v=FR3IAFBxvO4)
+
+
+
+ 모집게시글 목록 조회 |
+ 모집 게시글 등록 |
+ 모집 신청 참가/취소 |
+ 모집 성사 알림, 오픈채팅방 링크 |
+
+
+ |
+ |
+ |
+ |
+
+
+ 무한 스크롤을 통한 페이지네이션이 적용되어 있으며, 필터링이 가능합니다. |
+ 원하는 모임 모집 게시글을 작성할 수 있습니다. |
+ 모집중인 모임에 참가 신청/취소 할 수 있습니다. |
+ 모집이 완료되면 알림이 발송되며, 오픈채팅방 링크를 통해 모임 채팅방에 참여할 수 있습니다. |
+
+
+
+
-# 🫵 프로젝트 포인트
+
-강조하고 싶은 것들
+# 🫵 프로젝트 포인트
+
+- 중복 게시글 조회 방지를 위해 페이지네이션 방식 변경 및 쿼리 개선을 진행했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/feat-2a1dd8ea684d44ebb7176d5efbbc8aeb)
+- `custom hook`, `error class`, `ErrorBoundary`를 이용하여 API 핵심 로직과 에러 처리의 관심사를 분리하였고,
사용자에게 적절한 오류 화면을 보여주도록 했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/957e4b7034d64d0c8fa59f47e58c112d)
+- 성능 및 사용자 경험 개선을 위해 이미지 최적화와 SSR, Blur, Skeleton을 적용 했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/e959fbc871514fb29a407dfe3f2447b9)
+- 컴포넌트의 체계적인 설계와 문서화를 통해 개발자 경험을 향상시키고자 스토리북을 도입했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/a54eb762007f4ec185ee008a396d7a82)
+- CI/CD 파이프라인을 구축하고 지속적으로 개선했습니다. CI 시간을 약 40s 단축했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/CI-CD-e6f15386baa34b238884678928ff3c61)
diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml
index 8c881d38..a2c206b7 100644
--- a/backend/docker-compose.yml
+++ b/backend/docker-compose.yml
@@ -6,14 +6,12 @@ services:
image: nginx
restart: always
volumes:
- - ./nginx/dev:/etc/nginx/conf.d
+ - ./nginx/dev/conf:/etc/nginx/conf.d
- /letsencrypt/certbot/conf:/etc/letsencrypt
- /letsencrypt/certbot/www:/var/www/certbot
ports:
- 80:80
- 443:443
- depends_on:
- - moyeo-server
networks:
- backbone
command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"'''
@@ -31,17 +29,6 @@ services:
- backbone
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
- moyeo-server:
- container_name: moyeo-server
- image: ${DOCKER_SERVER_IMAGE}
- environment:
- NODE_ENV: development
- ports:
- - 3000:3000
- command: npm run start:dev
- networks:
- - backbone
-
moyeo-db:
container_name: moyeo-db
image: mysql:8.0
@@ -61,6 +48,28 @@ services:
networks:
- backbone
+ moyeo-server-green:
+ container_name: moyeo-server-green
+ image: ${DOCKER_SERVER_IMAGE}
+ environment:
+ NODE_ENV: development
+ expose:
+ - 3000
+ command: npm run start:dev
+ networks:
+ - backbone
+
+ moyeo-server-blue:
+ container_name: moyeo-server-blue
+ image: ${DOCKER_SERVER_IMAGE}
+ environment:
+ NODE_ENV: development
+ expose:
+ - 3000
+ command: npm run start:dev
+ networks:
+ - backbone
+
networks:
backbone:
driver: bridge
diff --git a/backend/local/nginx.conf b/backend/local/nginx.conf
index 6d7f6f05..7f2061a3 100644
--- a/backend/local/nginx.conf
+++ b/backend/local/nginx.conf
@@ -11,4 +11,10 @@ server {
proxy_pass http://backend-server;
proxy_http_version 1.1;
}
+
+ location = /v1/sse {
+ proxy_pass http://backend-server;
+ proxy_http_version 1.1;
+ proxy_read_timeout 600s;
+ }
}
diff --git a/backend/nginx/dev/nginx.conf b/backend/nginx/dev/conf/nginx.conf
similarity index 83%
rename from backend/nginx/dev/nginx.conf
rename to backend/nginx/dev/conf/nginx.conf
index 7260bdaf..fb47e49b 100644
--- a/backend/nginx/dev/nginx.conf
+++ b/backend/nginx/dev/conf/nginx.conf
@@ -1,6 +1,6 @@
upstream backend-server {
- server moyeo-server:3000;
+ server moyeo-server-blue:3000;
}
server {
@@ -35,4 +35,10 @@ server {
proxy_pass http://backend-server;
proxy_http_version 1.1;
}
+
+ location = /v1/sse {
+ proxy_pass http://backend-server;
+ proxy_http_version 1.1;
+ proxy_read_timeout 600s;
+ }
}
\ No newline at end of file
diff --git a/backend/nginx/prod/nginx.conf b/backend/nginx/prod/nginx.conf
index a79ce5d1..883528b1 100644
--- a/backend/nginx/prod/nginx.conf
+++ b/backend/nginx/prod/nginx.conf
@@ -35,4 +35,10 @@ server {
proxy_pass http://backend-server;
proxy_http_version 1.1;
}
+
+ location = /v1/sse {
+ proxy_pass http://backend-server;
+ proxy_http_version 1.1;
+ proxy_read_timeout 600s;
+ }
}
\ No newline at end of file
diff --git a/backend/package.json b/backend/package.json
index ce86f7f2..99bc6173 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -15,7 +15,7 @@
"start:prod": "node dist/main",
"start:local": "docker compose -f docker-compose.local.yml up -d --build",
"stop:local": "docker compose -f docker-compose.local.yml down",
- "reload:local": "docker compose -f docker-compose.local.yml up --force-recreate -d",
+ "reload:local": "docker compose -f docker-compose.local.yml up -d --build",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"test": "env-cmd -f .env.test jest",
diff --git a/backend/scripts/dev-deploy.sh b/backend/scripts/dev-deploy.sh
index 7a17aeff..a3d90d5c 100644
--- a/backend/scripts/dev-deploy.sh
+++ b/backend/scripts/dev-deploy.sh
@@ -12,8 +12,45 @@ echo -e $4 > .env
echo "create .env"
-# docker down
-docker compose down --rmi all --remove-orphans
-
-# docker up
-docker compose up -d --build
\ No newline at end of file
+RUNNING_APPLICATION=$(docker ps | grep moyeo-server-blue)
+DEFAULT_CONF="./nginx/dev/conf/nginx.conf"
+
+if [ -n "$RUNNING_APPLICATION" ];then
+ echo "green Deploy..."
+ docker compose pull moyeo-server-green
+ docker compose up -d moyeo-server-green
+ docker rmi $(docker images -f "dangling=true" -q)
+
+ while [ 1 == 1 ]; do
+ echo "green health check...."
+ REQUEST=$(docker exec moyeo-nginx curl http://moyeo-server-green:3000)
+ echo $REQUEST
+ if [ -n "$REQUEST" ]; then
+ break ;
+ fi
+ sleep 3
+ done;
+
+ sed -i 's/moyeo-server-blue/moyeo-server-green/g' $DEFAULT_CONF
+ docker exec moyeo-nginx service nginx reload
+ docker compose stop moyeo-server-blue
+else
+ echo "blue Deploy..."
+ docker compose pull moyeo-server-blue
+ docker compose up -d moyeo-server-blue
+ docker rmi $(docker images -f "dangling=true" -q)
+
+ while [ 1 == 1 ]; do
+ echo "blue health check...."
+ REQUEST=$(docker exec moyeo-nginx curl http://moyeo-server-blue:3000)
+ echo $REQUEST
+ if [ -n "$REQUEST" ]; then
+ break ;
+ fi
+ sleep 3
+ done;
+
+ sed -i 's/moyeo-server-green/moyeo-server-blue/g' $DEFAULT_CONF
+ docker exec moyeo-nginx service nginx reload
+ docker compose stop moyeo-server-green
+fi
diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts
deleted file mode 100644
index 2b594af2..00000000
--- a/backend/src/app.controller.spec.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Test, TestingModule } from '@nestjs/testing';
-import { AppController } from './app.controller';
-import { AppService } from './app.service';
-import { ResponseEntity } from '@common/response-entity';
-import { JwtAuthGuard } from '@common/guard/jwt-auth.guard';
-
-describe('AppController', () => {
- let appController: AppController;
-
- beforeEach(async () => {
- const app: TestingModule = await Test.createTestingModule({
- imports: [],
- controllers: [AppController],
- providers: [AppService],
- })
- .overrideGuard(JwtAuthGuard)
- .useValue(() => {
- return null;
- })
- .compile();
-
- appController = app.get(AppController);
- });
-
- describe('root', () => {
- test('example test', async () => {
- // given
- const id = 11;
-
- // when
- const result = appController.getHello({ id });
-
- // then
-
- expect(result).toEqual(ResponseEntity.OK_WITH_DATA('Hello World!'));
- });
- });
-});
diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts
deleted file mode 100644
index 8cc65927..00000000
--- a/backend/src/app.controller.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Controller, Get, HttpStatus, Param } from '@nestjs/common';
-import { ApiProperty, ApiTags } from '@nestjs/swagger';
-import { Type } from 'class-transformer';
-import { IsNumber } from 'class-validator';
-import { ResponseEntity } from '@common/response-entity';
-import { ApiSuccessResponse } from '@decorator/api-success-resposne.decorator';
-import { ApiErrorResponse } from '@decorator/api-error-response.decorator';
-import { BadParameterException } from '@exception/bad-parameter.exception';
-import { ApiNotFoundException } from '@exception/api-not-found.exception';
-import { JwtAuth } from '@decorator/jwt-auth.decorator';
-import { AppService } from '@src/app.service';
-
-export class ExampleDto {
- @IsNumber()
- @Type(() => Number)
- @ApiProperty({ example: 1 })
- id: number;
-}
-
-@Controller('test')
-@ApiTags('example')
-export class AppController {
- constructor(private readonly appService: AppService) {}
-
- @Get(':id')
- @JwtAuth()
- @ApiSuccessResponse(HttpStatus.OK, String)
- @ApiErrorResponse(BadParameterException, ApiNotFoundException)
- getHello(@Param() params: ExampleDto) {
- if (params.id < 10) throw new ApiNotFoundException();
- return ResponseEntity.OK_WITH_DATA(this.appService.getHello());
- }
-}
diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts
index 9842f253..3bd203fa 100644
--- a/backend/src/app.module.ts
+++ b/backend/src/app.module.ts
@@ -1,7 +1,5 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
-import { AppController } from './app.controller';
-import { AppService } from './app.service';
import { AppConfigModule } from '@config/app/config.module';
import { ApiSuccessLoggerMiddleware } from '@middleware/api-success-logger.middleware';
import { ApiExceptionLoggerMiddleware } from '@middleware/api-exception-logger.middleware';
@@ -17,6 +15,8 @@ import { GroupApplicationModule } from '@app/group-application/group-application
import { NotificationModule } from '@app/notification/notification.module';
import { CommentModule } from '@app/comment/comment.module';
import { AppConfigService } from '@common/config/app/config.service';
+import { SseModule } from '@common/module/sse/sse.module';
+import { SseController } from '@src/sse.controller';
@Module({
imports: [
@@ -33,15 +33,15 @@ import { AppConfigService } from '@common/config/app/config.service';
GroupApplicationModule,
NotificationModule,
CommentModule,
+ SseModule,
],
- controllers: [AppController],
- providers: [AppService],
+ controllers: [SseController],
})
export class AppModule implements NestModule {
- constructor(private readonly appConfigSerivce: AppConfigService) {}
+ constructor(private readonly appConfigService: AppConfigService) {}
configure(consumer: MiddlewareConsumer) {
- if (!this.appConfigSerivce.isTest()) {
+ if (!this.appConfigService.isTest()) {
consumer
.apply(ApiSuccessLoggerMiddleware, ApiExceptionLoggerMiddleware)
.forRoutes('*');
diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts
deleted file mode 100644
index 927d7cca..00000000
--- a/backend/src/app.service.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Injectable } from '@nestjs/common';
-
-@Injectable()
-export class AppService {
- getHello(): string {
- return 'Hello World!';
- }
-}
diff --git a/backend/src/app/group-article/dto/group-article-register-request.dto.ts b/backend/src/app/group-article/dto/group-article-register-request.dto.ts
index 424ee891..555db75a 100644
--- a/backend/src/app/group-article/dto/group-article-register-request.dto.ts
+++ b/backend/src/app/group-article/dto/group-article-register-request.dto.ts
@@ -32,6 +32,7 @@ export class GroupArticleRegisterRequest {
required: true,
})
@IsString()
+ @Length(1)
contents: string;
@ApiProperty({
@@ -66,6 +67,7 @@ export class GroupArticleRegisterRequest {
description: '썸네일 이미지가 저장되어있는 주소(url)',
required: true,
})
+ @Length(1)
@IsUrl()
thumbnail: string;
@@ -75,5 +77,6 @@ export class GroupArticleRegisterRequest {
required: false,
})
@IsString()
+ @Length(1)
chatUrl: string;
}
diff --git a/backend/src/app/group-article/group-article.controller.ts b/backend/src/app/group-article/group-article.controller.ts
index f951aed7..81c61110 100644
--- a/backend/src/app/group-article/group-article.controller.ts
+++ b/backend/src/app/group-article/group-article.controller.ts
@@ -144,7 +144,7 @@ export class GroupArticleController {
@Get('search')
@Version('2')
- @ApiSuccessResponse(HttpStatus.OK)
+ @ApiSuccessResponse(HttpStatus.OK, V2SearchGroupArticlesResponse)
async searchV2(@Query() query: V2SearchGroupArticlesRequest) {
const result = await this.groupArticleRepository.searchV2({
limit: query.limit,
diff --git a/backend/src/app/notification/dto/notification-sse.dto.ts b/backend/src/app/notification/dto/notification-sse.dto.ts
new file mode 100644
index 00000000..3464b952
--- /dev/null
+++ b/backend/src/app/notification/dto/notification-sse.dto.ts
@@ -0,0 +1,23 @@
+import { MessageEvent } from '@nestjs/common';
+import { GetUserNotificationResult } from '@app/notification/dto/get-user-notification-result.dto';
+import { UserNotification } from '@app/notification/entity/user-notification.entity';
+import { randomUUID } from 'crypto';
+
+export class NotificationSse implements MessageEvent {
+ id: string;
+ data: GetUserNotificationResult;
+ type: string;
+
+ retry: number;
+
+ static async from(userNotification: UserNotification) {
+ const notificationSse = new NotificationSse();
+ notificationSse.id = randomUUID();
+ notificationSse.data = await GetUserNotificationResult.from(
+ userNotification,
+ );
+ notificationSse.type = 'NOTIFICATION';
+ notificationSse.retry = 3 * 1000;
+ return notificationSse;
+ }
+}
diff --git a/backend/src/app/notification/dto/v2-get-user-notifications-response.dto.ts b/backend/src/app/notification/dto/v2-get-user-notifications-response.dto.ts
new file mode 100644
index 00000000..66d8e251
--- /dev/null
+++ b/backend/src/app/notification/dto/v2-get-user-notifications-response.dto.ts
@@ -0,0 +1,12 @@
+import { GetUserNotificationResult } from '@app/notification/dto/get-user-notification-result.dto';
+import { NoOffsetPageResult } from '@common/util/no-offset-page-result';
+import { Expose } from 'class-transformer';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class V2GetUserNotificationsResponse extends NoOffsetPageResult {
+ @Expose()
+ @ApiProperty({ type: GetUserNotificationResult, isArray: true })
+ get data(): GetUserNotificationResult[] {
+ return this._data;
+ }
+}
diff --git a/backend/src/app/notification/entity/notification.entity.ts b/backend/src/app/notification/entity/notification.entity.ts
index 61c1a55c..ce92c8af 100644
--- a/backend/src/app/notification/entity/notification.entity.ts
+++ b/backend/src/app/notification/entity/notification.entity.ts
@@ -12,6 +12,8 @@ import {
} from '@app/notification/entity/notification-contents';
import { GroupArticle } from '@app/group-article/entity/group-article.entity';
import { Comment } from '@src/app/comment/entity/comment.entity';
+import { User } from '@app/user/entity/user.entity';
+import { UserNotification } from '@app/notification/entity/user-notification.entity';
@Entity()
export class Notification {
@@ -62,4 +64,8 @@ export class Notification {
};
return notification;
}
+
+ createUserNotifications(users: User[]) {
+ return users.map((user) => UserNotification.create(user, this));
+ }
}
diff --git a/backend/src/app/notification/notification.controller.ts b/backend/src/app/notification/notification.controller.ts
index 40af80ad..271be3cd 100644
--- a/backend/src/app/notification/notification.controller.ts
+++ b/backend/src/app/notification/notification.controller.ts
@@ -8,6 +8,7 @@ import {
ParseIntPipe,
Patch,
Query,
+ Version,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NotificationSettingRepository } from '@app/notification/repository/notification-setting.repository';
@@ -27,6 +28,8 @@ import { UserNotificationRepository } from '@app/notification/repository/user-no
import { GetUserNotificationsResponse } from '@app/notification/dto/get-user-notifications-response.dto';
import { GetUserNotificationResult } from '@app/notification/dto/get-user-notification-result.dto';
import { UserNotificationNotFoundException } from '@app/notification/exception/user-notification-not-found.exception';
+import { NoOffsetPageRequest } from '@common/util/no-offset-page-request';
+import { V2GetUserNotificationsResponse } from '@app/notification/dto/v2-get-user-notifications-response.dto';
@Controller('notifications')
@ApiTags('Notification')
@@ -65,6 +68,34 @@ export class NotificationController {
);
}
+ @Get('/')
+ @Version('2')
+ @JwtAuth()
+ @ApiSuccessResponse(HttpStatus.OK, V2GetUserNotificationsResponse)
+ async getNotificationsV2(
+ @CurrentUser() user: User,
+ @Query() query: NoOffsetPageRequest,
+ ) {
+ const userNotifications =
+ await this.userNotificationRepository.getNotificationsV2({
+ user,
+ limit: query.limit,
+ nextId: query.nextId,
+ });
+
+ return ResponseEntity.OK_WITH_DATA(
+ new V2GetUserNotificationsResponse(
+ query.limit,
+ await Promise.all(
+ userNotifications.map((userNotification) =>
+ GetUserNotificationResult.from(userNotification),
+ ),
+ ),
+ query.nextId,
+ ),
+ );
+ }
+
@Get('settings')
@JwtAuth()
@ApiSuccessResponse(HttpStatus.OK, GetNotificationSettingsResponse, {
diff --git a/backend/src/app/notification/notification.listener.ts b/backend/src/app/notification/notification.listener.ts
index 3dd2c07c..786e9bf9 100644
--- a/backend/src/app/notification/notification.listener.ts
+++ b/backend/src/app/notification/notification.listener.ts
@@ -4,12 +4,13 @@ import { GroupSucceedEvent } from '@app/notification/event/group-succeed.event';
import { DataSource } from 'typeorm';
import { Notification } from '@app/notification/entity/notification.entity';
import { NOTIFICATION_SETTING_TYPE } from '@app/notification/constants/notification.constants';
-import { UserNotification } from '@app/notification/entity/user-notification.entity';
import { GroupApplicationRepository } from '@app/group-application/group-application.repository';
import { GroupFailedEvent } from '@app/notification/event/group-failed.event';
import { NotificationSettingRepository } from '@app/notification/repository/notification-setting.repository';
import { CommentAddedEvent } from '@app/notification/event/comment-added.event';
import { CommentRepository } from '@app/comment/comment.repository';
+import { SseService } from '@common/module/sse/sse.service';
+import { NotificationSse } from '@app/notification/dto/notification-sse.dto';
@Injectable()
export class NotificationListener {
@@ -20,6 +21,7 @@ export class NotificationListener {
private readonly groupApplicationRepository: GroupApplicationRepository,
private readonly notificationSettingRepository: NotificationSettingRepository,
private readonly commentRepository: CommentRepository,
+ private readonly sseService: SseService,
) {}
@OnEvent('group.succeed')
@@ -40,17 +42,29 @@ export class NotificationListener {
),
});
+ if (targetUsers.length === 0) {
+ return;
+ }
+
const notification =
Notification.createGroupSucceedNotification(groupArticle);
+ const userNotifications =
+ notification.createUserNotifications(targetUsers);
+
await this.dataSource.transaction(async (em) => {
await em.save(notification);
- await em.save(
- targetUsers.map((user) =>
- UserNotification.create(user, notification),
- ),
- );
+ await em.save(userNotifications);
});
+
+ await Promise.all(
+ userNotifications.map(async (userNotification) =>
+ this.sseService.emit(
+ await userNotification.user,
+ await NotificationSse.from(userNotification),
+ ),
+ ),
+ );
} catch (e) {
this.logger.error(e);
}
@@ -74,17 +88,29 @@ export class NotificationListener {
),
});
+ if (targetUsers.length === 0) {
+ return;
+ }
+
const notification =
Notification.createGroupFailedNotification(groupArticle);
+ const userNotifications =
+ notification.createUserNotifications(targetUsers);
+
await this.dataSource.transaction(async (em) => {
await em.save(notification);
- await em.save(
- targetUsers.map((user) =>
- UserNotification.create(user, notification),
- ),
- );
+ await em.save(userNotifications);
});
+
+ await Promise.all(
+ userNotifications.map(async (userNotification) =>
+ this.sseService.emit(
+ await userNotification.user,
+ await NotificationSse.from(userNotification),
+ ),
+ ),
+ );
} catch (e) {
this.logger.error(e);
}
@@ -107,19 +133,31 @@ export class NotificationListener {
.concat(groupArticle.userId),
});
+ if (targetUsers.length === 0) {
+ return;
+ }
+
const notification = await Notification.createCommentAddedNotification(
groupArticle,
comment,
);
+ const userNotifications =
+ notification.createUserNotifications(targetUsers);
+
await this.dataSource.transaction(async (em) => {
await em.save(notification);
- await em.save(
- targetUsers.map((user) =>
- UserNotification.create(user, notification),
- ),
- );
+ await em.save(userNotifications);
});
+
+ await Promise.all(
+ userNotifications.map(async (userNotification) =>
+ this.sseService.emit(
+ await userNotification.user,
+ await NotificationSse.from(userNotification),
+ ),
+ ),
+ );
} catch (e) {
this.logger.error(e);
}
diff --git a/backend/src/app/notification/notification.module.ts b/backend/src/app/notification/notification.module.ts
index 2b4ae44d..c89c2c75 100644
--- a/backend/src/app/notification/notification.module.ts
+++ b/backend/src/app/notification/notification.module.ts
@@ -6,9 +6,10 @@ import { NotificationListener } from '@app/notification/notification.listener';
import { GroupApplicationModule } from '@app/group-application/group-application.module';
import { CommentModule } from '@app/comment/comment.module';
import { UserNotificationRepository } from '@app/notification/repository/user-notification.repository';
+import { SseModule } from '@common/module/sse/sse.module';
@Module({
- imports: [GroupApplicationModule, CommentModule],
+ imports: [GroupApplicationModule, CommentModule, SseModule],
controllers: [NotificationController],
providers: [
NotificationService,
diff --git a/backend/src/app/notification/repository/user-notification.repository.ts b/backend/src/app/notification/repository/user-notification.repository.ts
index 2e84dbba..0375aff5 100644
--- a/backend/src/app/notification/repository/user-notification.repository.ts
+++ b/backend/src/app/notification/repository/user-notification.repository.ts
@@ -1,6 +1,6 @@
-import { DataSource, IsNull, Repository } from 'typeorm';
-import { UserNotification } from '@app/notification/entity/user-notification.entity';
import { Injectable } from '@nestjs/common';
+import { DataSource, IsNull, Repository, LessThan } from 'typeorm';
+import { UserNotification } from '@app/notification/entity/user-notification.entity';
import { User } from '@app/user/entity/user.entity';
@Injectable()
@@ -38,4 +38,27 @@ export class UserNotificationRepository extends Repository {
skip: offset,
});
}
+
+ getNotificationsV2({
+ user,
+ limit,
+ nextId,
+ }: {
+ user: User;
+ limit: number;
+ nextId?: number;
+ }) {
+ return this.find({
+ relations: {
+ notification: true,
+ },
+ where: {
+ userId: user.id,
+ deletedAt: IsNull(),
+ ...(nextId ? { id: LessThan(nextId) } : {}),
+ },
+ take: limit,
+ order: { id: 'DESC' },
+ });
+ }
}
diff --git a/backend/src/common/module/sse/sse-type.ts b/backend/src/common/module/sse/sse-type.ts
new file mode 100644
index 00000000..f8ec6e50
--- /dev/null
+++ b/backend/src/common/module/sse/sse-type.ts
@@ -0,0 +1,3 @@
+export enum SseType {
+ NOTIFICATION = 'NOTIFICATION',
+}
diff --git a/backend/src/common/module/sse/sse.module.ts b/backend/src/common/module/sse/sse.module.ts
new file mode 100644
index 00000000..5761fb32
--- /dev/null
+++ b/backend/src/common/module/sse/sse.module.ts
@@ -0,0 +1,8 @@
+import { Module } from '@nestjs/common';
+import { SseService } from '@common/module/sse/sse.service';
+
+@Module({
+ providers: [SseService],
+ exports: [SseService],
+})
+export class SseModule {}
diff --git a/backend/src/common/module/sse/sse.service.ts b/backend/src/common/module/sse/sse.service.ts
new file mode 100644
index 00000000..0fd51acd
--- /dev/null
+++ b/backend/src/common/module/sse/sse.service.ts
@@ -0,0 +1,21 @@
+import { Injectable, MessageEvent } from '@nestjs/common';
+import { EventEmitter } from 'events';
+import { fromEvent } from 'rxjs';
+import { User } from '@app/user/entity/user.entity';
+
+@Injectable()
+export class SseService {
+ private readonly eventEmitter: EventEmitter;
+
+ constructor() {
+ this.eventEmitter = new EventEmitter();
+ }
+
+ subscribe(user: User) {
+ return fromEvent(this.eventEmitter, `${user.id}`);
+ }
+
+ async emit(user: User, event: MessageEvent) {
+ this.eventEmitter.emit(`${user.id}`, event);
+ }
+}
diff --git a/backend/src/sse.controller.ts b/backend/src/sse.controller.ts
new file mode 100644
index 00000000..66e1bd72
--- /dev/null
+++ b/backend/src/sse.controller.ts
@@ -0,0 +1,16 @@
+import { Controller, Sse } from '@nestjs/common';
+import { SseService } from '@common/module/sse/sse.service';
+import { JwtAuth } from '@decorator/jwt-auth.decorator';
+import { CurrentUser } from '@decorator/current-user.decorator';
+import { User } from '@app/user/entity/user.entity';
+
+@Controller('sse')
+export class SseController {
+ constructor(private readonly sseService: SseService) {}
+
+ @Sse()
+ @JwtAuth()
+ sse(@CurrentUser() user: User) {
+ return this.sseService.subscribe(user);
+ }
+}
diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts
deleted file mode 100644
index 032894da..00000000
--- a/backend/test/app.e2e-spec.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Test, TestingModule } from '@nestjs/testing';
-import { INestApplication } from '@nestjs/common';
-import * as request from 'supertest';
-import { DataSource } from 'typeorm';
-import { AppModule } from '@src/app.module';
-import { setNestApp } from '@src/setNestApp';
-
-describe('AppController (e2e)', () => {
- let app: INestApplication;
- let dataSource: DataSource;
-
- beforeEach(async () => {
- const moduleFixture: TestingModule = await Test.createTestingModule({
- imports: [AppModule],
- }).compile();
-
- app = moduleFixture.createNestApplication();
-
- setNestApp(app);
-
- dataSource = app.get(DataSource);
-
- await app.init();
- });
-
- afterAll(async () => {
- await dataSource.destroy();
- });
-
- describe('GET /v1/test/:id', () => {
- const url = (id) => `/v1/test/${id}`;
-
- test('example test', async () => {
- // given
- const id = 11;
-
- // when
- const result = await request(app.getHttpServer()).get(url(id));
-
- // then
- expect(result.status).toEqual(401);
- });
- });
-});
diff --git a/backend/test/group-article.e2e-spec.ts b/backend/test/group-article.e2e-spec.ts
new file mode 100644
index 00000000..189ee95b
--- /dev/null
+++ b/backend/test/group-article.e2e-spec.ts
@@ -0,0 +1,1773 @@
+import { INestApplication } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import { AppModule } from '@src/app.module';
+import { GroupArticle } from '@app/group-article/entity/group-article.entity';
+import { GroupCategory } from '@app/group-article/entity/group-category.entity';
+import { getGroupArticleFixture } from '@app/group-article/__test__/group-article.fixture';
+import { getGroupCategoryFixture } from '@app/group-article/__test__/group-category.fixture';
+import { getGroupFixture } from '@app/group-article/__test__/group.fixture';
+import { User } from '@app/user/entity/user.entity';
+import { getUserFixture } from '@app/user/__test__/user.fixture';
+import { setNestApp } from '@src/setNestApp';
+import { DataSource } from 'typeorm';
+import * as request from 'supertest';
+import { JwtTokenService } from '@common/module/jwt-token/jwt-token.service';
+import { setCookie } from './utils/jwt-test.utils';
+import {
+ CATEGORY,
+ GROUP_STATUS,
+ LOCATION,
+} from '@app/group-article/constants/group-article.constants';
+import { GroupArticleRegisterRequest } from '@app/group-article/dto/group-article-register-request.dto';
+import { UpdateGroupArticleRequest } from '@src/app/group-article/dto/update-group-article-request.dto';
+import { GroupApplicationService } from '@src/app/group-application/group-application.service';
+
+describe('Group Application (e2e)', () => {
+ let app: INestApplication;
+ let dataSource: DataSource;
+
+ beforeAll(async () => {
+ const moduleFixture = await Test.createTestingModule({
+ imports: [AppModule],
+ }).compile();
+
+ app = moduleFixture.createNestApplication();
+
+ setNestApp(app);
+
+ dataSource = app.get(DataSource);
+
+ await app.init();
+ });
+
+ beforeEach(async () => {
+ const groupCategoryRepository = dataSource.getRepository(GroupCategory);
+ const categories = getGroupCategoryFixture();
+ await groupCategoryRepository.save(categories);
+
+ const userRepository = dataSource.getRepository(User);
+ const user1 = getUserFixture({ id: 1 });
+ const user2 = getUserFixture({ id: 2 });
+ await userRepository.save([user1, user2]);
+
+ const group1 = getGroupFixture(categories[1], { id: 1, maxCapacity: 2 });
+ const group2 = getGroupFixture(categories[1], { id: 2 });
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle1 = await getGroupArticleFixture(group1, {
+ id: 1,
+ user: new Promise((res) => res(user1)),
+ userId: user1.id,
+ });
+ const groupArticle2 = await getGroupArticleFixture(group2, {
+ id: 2,
+ user: new Promise((res) => res(user1)),
+ userId: user1.id,
+ });
+ await groupArticleRepository.save([groupArticle1, groupArticle2]);
+ });
+
+ afterEach(async () => {
+ await dataSource.synchronize(true);
+ });
+
+ afterAll(async () => {
+ await dataSource.destroy();
+ });
+
+ describe('모집 게시글 생성 POST /v1/group-articles', () => {
+ const url = () => `/v1/group-articles`;
+
+ test('모집게시글을 등록하면 201 코드와 group article의 아이디(id: 3)를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleRquest = new GroupArticleRegisterRequest();
+ groupArticleRquest.title = 'CS 스터디';
+ groupArticleRquest.contents = 'Hello';
+ groupArticleRquest.category = CATEGORY.STUDY;
+ groupArticleRquest.location = LOCATION.ONLINE;
+ groupArticleRquest.maxCapacity = 5;
+ groupArticleRquest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ groupArticleRquest.chatUrl = '채팅 url';
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url())
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(groupArticleRquest);
+
+ // then
+ expect(result.status).toEqual(201);
+ expect(result.body.data.id).toEqual(3);
+ });
+
+ test('모집게시글을 등록할 때 contents를 적지 않으면 400 에러를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleRquest = new GroupArticleRegisterRequest();
+ groupArticleRquest.title = 'CS 스터디';
+ groupArticleRquest.contents = '';
+ groupArticleRquest.category = CATEGORY.STUDY;
+ groupArticleRquest.location = LOCATION.ONLINE;
+ groupArticleRquest.maxCapacity = 5;
+ groupArticleRquest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ groupArticleRquest.chatUrl = '채팅 url';
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url())
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(groupArticleRquest);
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글을 등록할 때 타이틀이 0자면 400 에러를 보낸다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleRquest = new GroupArticleRegisterRequest();
+ groupArticleRquest.title = '';
+ groupArticleRquest.contents = 'Hello';
+ groupArticleRquest.category = CATEGORY.STUDY;
+ groupArticleRquest.location = LOCATION.ONLINE;
+ groupArticleRquest.maxCapacity = 5;
+ groupArticleRquest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ groupArticleRquest.chatUrl = '채팅 url';
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url())
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(groupArticleRquest);
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글을 등록할 때 타이틀이 100자 초과면 400 에러를 보낸다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleRquest = new GroupArticleRegisterRequest();
+ groupArticleRquest.title = 'a'.repeat(101);
+ groupArticleRquest.contents = 'Hello';
+ groupArticleRquest.category = CATEGORY.STUDY;
+ groupArticleRquest.location = LOCATION.ONLINE;
+ groupArticleRquest.maxCapacity = 5;
+ groupArticleRquest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ groupArticleRquest.chatUrl = '채팅 url';
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url())
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(groupArticleRquest);
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글을 등록할 때 정해진 카테고리값을 입력하지 않으면 400에러를 뱉는다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url())
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send({
+ title: 'study',
+ contents: 'Hello',
+ category: 'nothing',
+ location: LOCATION.ONLINE,
+ maxCapacity: 10,
+ thumbnail:
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png',
+ chatUrl: 'url',
+ });
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글을 등록할 때 정해진 위치값을 입력하지 않으면 400에러를 뱉는다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url())
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send({
+ title: 'study',
+ contents: 'Hello',
+ category: CATEGORY.STUDY,
+ location: 'nothing',
+ maxCapacity: 10,
+ thumbnail:
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png',
+ chatUrl: 'url',
+ });
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글을 등록할 때 최대 인원을 초과하면 400 에러를 보여준다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleRquest = new GroupArticleRegisterRequest();
+ groupArticleRquest.title = 'CS 스터디';
+ groupArticleRquest.contents = 'Hello';
+ groupArticleRquest.category = CATEGORY.STUDY;
+ groupArticleRquest.location = LOCATION.ONLINE;
+ groupArticleRquest.maxCapacity = 30;
+ groupArticleRquest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ groupArticleRquest.chatUrl = '채팅 url';
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url())
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(groupArticleRquest);
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => {
+ // given
+
+ // when
+ const result = await request(app.getHttpServer()).post(url());
+
+ // then
+ expect(result.status).toEqual(401);
+ });
+ });
+
+ describe('모집 게시글 모집 중단 POST /group-articles/:id/recruitment-cancel', () => {
+ const url = (id: number) => `/v1/group-articles/${id}/recruitment-cancel`;
+
+ test('모집게시글의 상태를 정상적으로 모집 취소로 바꾼다면 204 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(204);
+ });
+
+ test('모집게시글의 상태가 이미 모집완료라면 400 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+
+ const groupArticleId = 1;
+ const groupAriticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle = await groupAriticleRepository.findOneBy({
+ id: groupArticleId,
+ });
+ groupArticle.complete(user);
+ await groupAriticleRepository.save(groupArticle);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글의 상태가 이미 모집취소 상태라면 400 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+
+ const groupArticleId = 1;
+ const groupAriticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle = await groupAriticleRepository.findOneBy({
+ id: groupArticleId,
+ });
+ groupArticle.cancel(user);
+ await groupAriticleRepository.save(groupArticle);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => {
+ // given
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer()).post(
+ url(groupArticleId),
+ );
+
+ // then
+ expect(result.status).toEqual(401);
+ });
+
+ test('글 작성자가 아닌 다른 유저가 모집 상태를 바꾸려고 하면 403 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 2 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(403);
+ });
+
+ test('아이디에 해당하는 모집게시글이 없다면 404 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 10000;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(404);
+ });
+ });
+
+ describe('모집 게시글 모집 완료 POST /group-articles/:id/recruitment-complete', () => {
+ const url = (id: number) => `/v1/group-articles/${id}/recruitment-complete`;
+
+ test('모집게시글의 상태를 정상적으로 모집 완료로 바꾼다면 204 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(204);
+ });
+
+ test('모집게시글의 상태가 이미 모집완료라면 400 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+
+ const groupArticleId = 1;
+ const groupAriticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle = await groupAriticleRepository.findOneBy({
+ id: groupArticleId,
+ });
+ groupArticle.complete(user);
+ await groupAriticleRepository.save(groupArticle);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글의 상태가 이미 모집취소 상태라면 400 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+
+ const groupArticleId = 1;
+ const groupAriticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle = await groupAriticleRepository.findOneBy({
+ id: groupArticleId,
+ });
+ groupArticle.cancel(user);
+ await groupAriticleRepository.save(groupArticle);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => {
+ // given
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer()).post(
+ url(groupArticleId),
+ );
+
+ // then
+ expect(result.status).toEqual(401);
+ });
+
+ test('글 작성자가 아닌 다른 유저가 모집 상태를 바꾸려고 하면 403 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 2 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(403);
+ });
+
+ test('아이디에 해당하는 모집게시글이 없다면 404 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 10000;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .post(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(404);
+ });
+ });
+
+ describe('모집 게시글 수정 PUT /group-articles/:id', () => {
+ const url = (id: number) => `/v1/group-articles/${id}`;
+
+ test('모집게시글을 정상적으로 수정하면 204 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const updateGroupArticleRequest = new UpdateGroupArticleRequest();
+ updateGroupArticleRequest.title = 'CS 스터디';
+ updateGroupArticleRequest.contents = 'Hello';
+ updateGroupArticleRequest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ updateGroupArticleRequest.chatUrl = '채팅 url';
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .put(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(updateGroupArticleRequest);
+
+ // then
+ expect(result.status).toEqual(204);
+ });
+
+ test('모집게시글을 수정할 때 제목이 없으면 400 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const updateGroupArticleRequest = new UpdateGroupArticleRequest();
+ updateGroupArticleRequest.title = '';
+ updateGroupArticleRequest.contents = 'Hello';
+ updateGroupArticleRequest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ updateGroupArticleRequest.chatUrl = '채팅 url';
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .put(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(updateGroupArticleRequest);
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글을 수정할 때 콘텐츠가 없으면 400 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const updateGroupArticleRequest = new UpdateGroupArticleRequest();
+ updateGroupArticleRequest.title = 'CS 스터디';
+ updateGroupArticleRequest.contents = '';
+ updateGroupArticleRequest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ updateGroupArticleRequest.chatUrl = '채팅 url';
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .put(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(updateGroupArticleRequest);
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글을 수정할 때 썸네일이 없으면 400 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const updateGroupArticleRequest = new UpdateGroupArticleRequest();
+ updateGroupArticleRequest.title = 'CS 스터디';
+ updateGroupArticleRequest.contents = 'Hello';
+ updateGroupArticleRequest.thumbnail = '';
+ updateGroupArticleRequest.chatUrl = '채팅 url';
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .put(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(updateGroupArticleRequest);
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모집게시글을 수정할 때 채팅 URL이 없으면 400 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const updateGroupArticleRequest = new UpdateGroupArticleRequest();
+ updateGroupArticleRequest.title = 'CS 스터디';
+ updateGroupArticleRequest.contents = 'Hello';
+ updateGroupArticleRequest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ updateGroupArticleRequest.chatUrl = '';
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .put(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(updateGroupArticleRequest);
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => {
+ // given
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer()).put(
+ url(groupArticleId),
+ );
+
+ // then
+ expect(result.status).toEqual(401);
+ });
+
+ test('모집게시글을 수정할 때 작성자가 아니라면 403 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 2 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const updateGroupArticleRequest = new UpdateGroupArticleRequest();
+ updateGroupArticleRequest.title = 'CS 스터디';
+ updateGroupArticleRequest.contents = 'Hello';
+ updateGroupArticleRequest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ updateGroupArticleRequest.chatUrl = 'chat url';
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .put(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(updateGroupArticleRequest);
+
+ // then
+ expect(result.status).toEqual(403);
+ });
+
+ test('모집게시글을 수정할 때 없는 게시물에 접근하면 404 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const updateGroupArticleRequest = new UpdateGroupArticleRequest();
+ updateGroupArticleRequest.title = 'CS 스터디';
+ updateGroupArticleRequest.contents = 'Hello';
+ updateGroupArticleRequest.thumbnail =
+ 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png';
+ updateGroupArticleRequest.chatUrl = 'chat url';
+ const groupArticleId = 10000;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .put(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) })
+ .send(updateGroupArticleRequest);
+
+ // then
+ expect(result.status).toEqual(404);
+ });
+ });
+
+ describe('모집 게시글 단일 조회 GET /group-articles/:id', () => {
+ const url = (id: number) => `/v1/group-articles/${id}`;
+
+ test('모집게시글을 정상조회하면 200코드와 게시글 상세정보를 준다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ const groupArticle = await dataSource
+ .getRepository(GroupArticle)
+ .findOneBy({ id: 1 });
+
+ // when
+ const result = await request(app.getHttpServer())
+ .get(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.id).toEqual(groupArticle.id);
+ expect(result.body.data.title).toEqual(groupArticle.title);
+ expect(result.body.data.contents).toEqual(groupArticle.contents);
+ expect(result.body.data.author).toEqual({
+ id: user.id,
+ userName: user.userName,
+ profileImage: user.profileImage,
+ });
+ expect(result.body.data.category).toEqual(
+ groupArticle.group.category.name,
+ );
+ expect(result.body.data.location).toEqual(groupArticle.group.location);
+ expect(result.body.data.thumbnail).toEqual(groupArticle.group.thumbnail);
+ expect(result.body.data.status).toEqual(groupArticle.group.status);
+ });
+
+ test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => {
+ // given
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url(groupArticleId),
+ );
+
+ // then
+ expect(result.status).toEqual(401);
+ });
+
+ test('해당하는 모집 게시글이 존재하지 않는다면 404코드를 준다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 10000;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .get(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(404);
+ });
+ });
+
+ describe('모집 게시글 채팅방 URL 조회 GET /group-articles/:id/chat-url', () => {
+ const url = (id: number) => `/v1/group-articles/${id}/chat-url`;
+
+ test('모집게시글이 모집 완료된 상태로 Chat URL을 정상조회하면 200코드와 채팅 URL을 전달한다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const userRepository = dataSource.getRepository(User);
+ const author = await userRepository.findOneBy({ id: 1 });
+ const user = await userRepository.findOneBy({ id: 2 });
+ const accessToken = jwtService.generateAccessToken(user);
+
+ const groupArticleId = 2;
+ const groupApplicationService = app.get(GroupApplicationService);
+ await groupApplicationService.joinGroup(user, groupArticleId);
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle = await groupArticleRepository.findOneBy({
+ id: groupArticleId,
+ });
+ groupArticle.complete(author);
+ await groupArticleRepository.save(groupArticle);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .get(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.chatUrl).toEqual(groupArticle.group.chatUrl);
+ });
+
+ test('모임게시글이 아직 모집 중이라면 400코드를 준다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .get(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('모임게시글이 모집 실패 상태라면 400코드를 준다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle = await groupArticleRepository.findOneBy({ id: 1 });
+ groupArticle.cancel(user);
+ await groupArticleRepository.save(groupArticle);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .get(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(400);
+ });
+
+ test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => {
+ // given
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url(groupArticleId),
+ );
+
+ // then
+ expect(result.status).toEqual(401);
+ });
+
+ test('모집게시글의 참가자가 아니라면 403 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const author = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const user = await dataSource.getRepository(User).findOneBy({ id: 2 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle = await groupArticleRepository.findOneBy({ id: 1 });
+ groupArticle.complete(author);
+ await groupArticleRepository.save(groupArticle);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .get(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(403);
+ });
+
+ test('해당하는 모집 게시글이 존재하지 않는다면 404코드를 준다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 10000;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .get(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(404);
+ });
+ });
+
+ describe('모집게시글 카테고리 조회 GET /group-articles/categories', () => {
+ const url = () => `/v1/group-articles/categories`;
+
+ test('카테고리를 정상 조회하면 200 OK와 카테고리 정보를 던져준다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const userRepository = dataSource.getRepository(User);
+ const user = await userRepository.findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+
+ // when
+ const result = await request(app.getHttpServer())
+ .get(url())
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data[0]).toEqual({
+ id: 1,
+ name: 'MEAL',
+ });
+ expect(result.body.data[1]).toEqual({
+ id: 2,
+ name: 'STUDY',
+ });
+ expect(result.body.data[2]).toEqual({
+ id: 3,
+ name: 'ETC',
+ });
+ expect(result.body.data[3]).toEqual({
+ id: 4,
+ name: 'COMPETITION',
+ });
+ expect(result.body.data[4]).toEqual({
+ id: 5,
+ name: 'PROJECT',
+ });
+ });
+
+ test('JWT 토큰이 없어도 200 OK와 카테고리 데이터를 준다.', async () => {
+ // given
+
+ // when
+ const result = await request(app.getHttpServer()).get(url());
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data[0]).toEqual({
+ id: 1,
+ name: 'MEAL',
+ });
+ expect(result.body.data[1]).toEqual({
+ id: 2,
+ name: 'STUDY',
+ });
+ expect(result.body.data[2]).toEqual({
+ id: 3,
+ name: 'ETC',
+ });
+ expect(result.body.data[3]).toEqual({
+ id: 4,
+ name: 'COMPETITION',
+ });
+ expect(result.body.data[4]).toEqual({
+ id: 5,
+ name: 'PROJECT',
+ });
+ });
+ });
+
+ describe('모집 게시글 리스트 조회 GET /v1/group-articles/search?currentPage={currentPage}&countPerPage={countPerPage}category={category}&location={location}&status={status}', () => {
+ const url = ({
+ currentPage = 1,
+ countPerPage = 10,
+ category = '',
+ location = '',
+ status = '',
+ }: {
+ currentPage: number;
+ countPerPage: number;
+ category: string;
+ location: string;
+ status: string;
+ }) => {
+ let url = `/v1/group-articles/search?currentPage=${currentPage}&countPerPage=${countPerPage}`;
+ if (category.length > 0) url += `&category=${category}`;
+ if (location.length > 0) url += `&location=${location}`;
+ if (status.length > 0) url += `&status=${status}`;
+ return url;
+ };
+
+ test('모집게시글을 조건에 맞게 검색하면 200 코드와 게시글 데이터를 전달해준다.', async () => {
+ // given
+ const currentPage = 1;
+ const countPerPage = 10;
+ const category = CATEGORY.STUDY;
+ const location = LOCATION.ONLINE;
+ const status = GROUP_STATUS.PROGRESS;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ countPerPage, currentPage, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.totalPage).toEqual(1);
+ expect(result.body.data.totalCount).toEqual(2);
+ expect(result.body.data.currentPage).toEqual(1);
+ expect(result.body.data.countPerPage).toEqual(10);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(2);
+ expect(result.body.data.data[1].id).toEqual(1);
+
+ // 검색 조건이 맞는지
+ expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS);
+ });
+
+ test('모집게시글을 조건에 맞게 검색하면 200 코드와 게시글 데이터를 전달해준다.', async () => {
+ // given
+ const currentPage = 1;
+ const countPerPage = 10;
+ const category = CATEGORY.STUDY;
+ const location = LOCATION.ONLINE;
+ const status = GROUP_STATUS.PROGRESS;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ countPerPage, currentPage, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.totalPage).toEqual(1);
+ expect(result.body.data.totalCount).toEqual(2);
+ expect(result.body.data.currentPage).toEqual(1);
+ expect(result.body.data.countPerPage).toEqual(10);
+ expect(result.body.data.data[0].id).toEqual(2);
+ expect(result.body.data.data[1].id).toEqual(1);
+ });
+
+ test('모집게시글을 카테고리 없이 조회하면 모든 카테고리에 해당하는 게시글을 불러온다.', async () => {
+ // given
+ const categories = getGroupCategoryFixture();
+
+ const userRepository = dataSource.getRepository(User);
+ const user = getUserFixture({ id: 3 });
+ await userRepository.save(user);
+
+ const group1 = getGroupFixture(categories[2], {
+ id: 3,
+ maxCapacity: 5,
+ status: GROUP_STATUS.SUCCEED,
+ });
+ const group2 = getGroupFixture(categories[3], {
+ id: 4,
+ status: GROUP_STATUS.FAIL,
+ });
+ const group3 = getGroupFixture(categories[3], {
+ id: 5,
+ status: GROUP_STATUS.PROGRESS,
+ });
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle1 = await getGroupArticleFixture(group1, {
+ id: 3,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle2 = await getGroupArticleFixture(group2, {
+ id: 4,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle3 = await getGroupArticleFixture(group3, {
+ id: 5,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ await groupArticleRepository.save([
+ groupArticle1,
+ groupArticle2,
+ groupArticle3,
+ ]);
+
+ const currentPage = 1;
+ const countPerPage = 10;
+ const category = '';
+ const location = LOCATION.ONLINE;
+ const status = GROUP_STATUS.PROGRESS;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ countPerPage, currentPage, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.totalPage).toEqual(1);
+ expect(result.body.data.totalCount).toEqual(3);
+ expect(result.body.data.currentPage).toEqual(1);
+ expect(result.body.data.countPerPage).toEqual(10);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(5);
+ expect(result.body.data.data[1].id).toEqual(2);
+ expect(result.body.data.data[2].id).toEqual(1);
+
+ //검색조건이 맞는지
+ expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[2].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[2].status).toEqual(GROUP_STATUS.PROGRESS);
+ });
+
+ test('모집게시글을 location 없이 조회하면 모든 location에 해당하고 검색 조건이 맞는 게시글을 불러온다.', async () => {
+ // given
+ const categories = getGroupCategoryFixture();
+
+ const userRepository = dataSource.getRepository(User);
+ const user = getUserFixture({ id: 3 });
+ await userRepository.save(user);
+
+ const group1 = getGroupFixture(categories[2], {
+ id: 3,
+ maxCapacity: 5,
+ status: GROUP_STATUS.SUCCEED,
+ });
+ const group2 = getGroupFixture(categories[3], {
+ id: 4,
+ status: GROUP_STATUS.PROGRESS,
+ location: LOCATION.SEOUL,
+ });
+ const group3 = getGroupFixture(categories[1], {
+ id: 5,
+ status: GROUP_STATUS.PROGRESS,
+ location: LOCATION.BUSAN,
+ });
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle1 = await getGroupArticleFixture(group1, {
+ id: 3,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle2 = await getGroupArticleFixture(group2, {
+ id: 4,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle3 = await getGroupArticleFixture(group3, {
+ id: 5,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ await groupArticleRepository.save([
+ groupArticle1,
+ groupArticle2,
+ groupArticle3,
+ ]);
+
+ const currentPage = 1;
+ const countPerPage = 10;
+ const category = CATEGORY.STUDY;
+ const location = '';
+ const status = GROUP_STATUS.PROGRESS;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ countPerPage, currentPage, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.totalPage).toEqual(1);
+ expect(result.body.data.totalCount).toEqual(3);
+ expect(result.body.data.currentPage).toEqual(1);
+ expect(result.body.data.countPerPage).toEqual(10);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(5);
+ expect(result.body.data.data[1].id).toEqual(2);
+ expect(result.body.data.data[2].id).toEqual(1);
+
+ //검색조건이 맞는지
+ expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[2].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[2].status).toEqual(GROUP_STATUS.PROGRESS);
+ });
+
+ test('모집게시글을 location 없이 조회하면 모든 status에 해당하고 검색 조건이 맞는 해당하는 게시글을 불러온다.', async () => {
+ // given
+ const categories = getGroupCategoryFixture();
+
+ const userRepository = dataSource.getRepository(User);
+ const user = getUserFixture({ id: 3 });
+ await userRepository.save(user);
+
+ const group1 = getGroupFixture(categories[1], {
+ id: 3,
+ maxCapacity: 5,
+ status: GROUP_STATUS.SUCCEED,
+ location: LOCATION.ONLINE,
+ });
+ const group2 = getGroupFixture(categories[3], {
+ id: 4,
+ status: GROUP_STATUS.PROGRESS,
+ location: LOCATION.SEOUL,
+ });
+ const group3 = getGroupFixture(categories[1], {
+ id: 5,
+ status: GROUP_STATUS.SUCCEED,
+ location: LOCATION.ONLINE,
+ });
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle1 = await getGroupArticleFixture(group1, {
+ id: 3,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle2 = await getGroupArticleFixture(group2, {
+ id: 4,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle3 = await getGroupArticleFixture(group3, {
+ id: 5,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ await groupArticleRepository.save([
+ groupArticle1,
+ groupArticle2,
+ groupArticle3,
+ ]);
+
+ const currentPage = 1;
+ const countPerPage = 10;
+ const category = CATEGORY.STUDY;
+ const location = LOCATION.ONLINE;
+ const status = '';
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ countPerPage, currentPage, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.totalPage).toEqual(1);
+ expect(result.body.data.totalCount).toEqual(4);
+ expect(result.body.data.currentPage).toEqual(1);
+ expect(result.body.data.countPerPage).toEqual(10);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(5);
+ expect(result.body.data.data[1].id).toEqual(3);
+ expect(result.body.data.data[2].id).toEqual(2);
+ expect(result.body.data.data[3].id).toEqual(1);
+
+ //검색조건이 맞는지
+ expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[2].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[3].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[2].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[3].location).toEqual(LOCATION.ONLINE);
+ });
+
+ test('모집게시글을 검색 조건 없이 조회하면 모든 게시글을 조회한다.', async () => {
+ // given
+ const categories = getGroupCategoryFixture();
+
+ const userRepository = dataSource.getRepository(User);
+ const user = getUserFixture({ id: 3 });
+ await userRepository.save(user);
+
+ const group1 = getGroupFixture(categories[1], {
+ id: 3,
+ maxCapacity: 5,
+ status: GROUP_STATUS.FAIL,
+ location: LOCATION.BUSAN,
+ });
+ const group2 = getGroupFixture(categories[3], {
+ id: 4,
+ status: GROUP_STATUS.PROGRESS,
+ location: LOCATION.SEOUL,
+ });
+ const group3 = getGroupFixture(categories[2], {
+ id: 5,
+ status: GROUP_STATUS.SUCCEED,
+ location: LOCATION.ONLINE,
+ });
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle1 = await getGroupArticleFixture(group1, {
+ id: 3,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle2 = await getGroupArticleFixture(group2, {
+ id: 4,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle3 = await getGroupArticleFixture(group3, {
+ id: 5,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ await groupArticleRepository.save([
+ groupArticle1,
+ groupArticle2,
+ groupArticle3,
+ ]);
+
+ const currentPage = 1;
+ const countPerPage = 10;
+ const category = '';
+ const location = '';
+ const status = '';
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ countPerPage, currentPage, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.totalPage).toEqual(1);
+ expect(result.body.data.totalCount).toEqual(5);
+ expect(result.body.data.currentPage).toEqual(1);
+ expect(result.body.data.countPerPage).toEqual(10);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(5);
+ expect(result.body.data.data[1].id).toEqual(4);
+ expect(result.body.data.data[2].id).toEqual(3);
+ expect(result.body.data.data[3].id).toEqual(2);
+ expect(result.body.data.data[4].id).toEqual(1);
+ });
+ });
+
+ describe('모집 게시글 리스트 조회 GET /v2/group-articles/search?currentPage={currentPage}&countPerPage={countPerPage}category={category}&location={location}&status={status}', () => {
+ const url = ({
+ limit = 10,
+ nextId = 50,
+ category = '',
+ location = '',
+ status = '',
+ }: {
+ limit: number;
+ nextId: number;
+ category: string;
+ location: string;
+ status: string;
+ }) => {
+ let url = `/v2/group-articles/search?limit=${limit}&nextId=${nextId}`;
+ if (category.length > 0) url += `&category=${category}`;
+ if (location.length > 0) url += `&location=${location}`;
+ if (status.length > 0) url += `&status=${status}`;
+ return url;
+ };
+
+ test('모집게시글을 조건에 맞게 검색하면 200 코드와 게시글 데이터를 전달해준다.', async () => {
+ // given
+ const limit = 10;
+ const nextId = 50;
+ const category = CATEGORY.STUDY;
+ const location = LOCATION.ONLINE;
+ const status = GROUP_STATUS.PROGRESS;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ limit, nextId, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.limit).toEqual(limit);
+ expect(result.body.data.beforeNextId).toEqual(nextId);
+ expect(result.body.data.nextId).toEqual(1);
+ expect(result.body.data.isLast).toEqual(true);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(2);
+ expect(result.body.data.data[1].id).toEqual(1);
+
+ // 검색 조건이 맞는지
+ expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS);
+ });
+
+ test('모집게시글을 조건에 맞게 검색하면 200 코드와 게시글 데이터를 전달해준다.', async () => {
+ // given
+ const limit = 10;
+ const nextId = 50;
+ const category = CATEGORY.STUDY;
+ const location = LOCATION.ONLINE;
+ const status = GROUP_STATUS.PROGRESS;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ limit, nextId, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.limit).toEqual(limit);
+ expect(result.body.data.beforeNextId).toEqual(nextId);
+ expect(result.body.data.nextId).toEqual(1);
+ expect(result.body.data.isLast).toEqual(true);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(2);
+ expect(result.body.data.data[1].id).toEqual(1);
+
+ //검색조건이 맞는지
+ expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS);
+ });
+
+ test('모집게시글을 카테고리 없이 조회하면 모든 카테고리에 해당하는 게시글을 불러온다.', async () => {
+ // given
+ const categories = getGroupCategoryFixture();
+
+ const userRepository = dataSource.getRepository(User);
+ const user = getUserFixture({ id: 3 });
+ await userRepository.save(user);
+
+ const group1 = getGroupFixture(categories[2], {
+ id: 3,
+ maxCapacity: 5,
+ status: GROUP_STATUS.SUCCEED,
+ });
+ const group2 = getGroupFixture(categories[3], {
+ id: 4,
+ status: GROUP_STATUS.FAIL,
+ });
+ const group3 = getGroupFixture(categories[3], {
+ id: 5,
+ status: GROUP_STATUS.PROGRESS,
+ });
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle1 = await getGroupArticleFixture(group1, {
+ id: 3,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle2 = await getGroupArticleFixture(group2, {
+ id: 4,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle3 = await getGroupArticleFixture(group3, {
+ id: 5,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ await groupArticleRepository.save([
+ groupArticle1,
+ groupArticle2,
+ groupArticle3,
+ ]);
+
+ const limit = 10;
+ const nextId = 50;
+ const category = '';
+ const location = LOCATION.ONLINE;
+ const status = GROUP_STATUS.PROGRESS;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ limit, nextId, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.limit).toEqual(limit);
+ expect(result.body.data.beforeNextId).toEqual(nextId);
+ expect(result.body.data.nextId).toEqual(1);
+ expect(result.body.data.isLast).toEqual(true);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(5);
+ expect(result.body.data.data[1].id).toEqual(2);
+ expect(result.body.data.data[2].id).toEqual(1);
+
+ //검색조건이 맞는지
+ expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[2].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[2].status).toEqual(GROUP_STATUS.PROGRESS);
+ });
+
+ test('모집게시글을 location 없이 조회하면 모든 location에 해당하고 검색 조건이 맞는 게시글을 불러온다.', async () => {
+ // given
+ const categories = getGroupCategoryFixture();
+
+ const userRepository = dataSource.getRepository(User);
+ const user = getUserFixture({ id: 3 });
+ await userRepository.save(user);
+
+ const group1 = getGroupFixture(categories[2], {
+ id: 3,
+ maxCapacity: 5,
+ status: GROUP_STATUS.SUCCEED,
+ });
+ const group2 = getGroupFixture(categories[3], {
+ id: 4,
+ status: GROUP_STATUS.PROGRESS,
+ location: LOCATION.SEOUL,
+ });
+ const group3 = getGroupFixture(categories[1], {
+ id: 5,
+ status: GROUP_STATUS.PROGRESS,
+ location: LOCATION.BUSAN,
+ });
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle1 = await getGroupArticleFixture(group1, {
+ id: 3,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle2 = await getGroupArticleFixture(group2, {
+ id: 4,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle3 = await getGroupArticleFixture(group3, {
+ id: 5,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ await groupArticleRepository.save([
+ groupArticle1,
+ groupArticle2,
+ groupArticle3,
+ ]);
+
+ const limit = 10;
+ const nextId = 50;
+ const category = CATEGORY.STUDY;
+ const location = '';
+ const status = GROUP_STATUS.PROGRESS;
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ limit, nextId, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.limit).toEqual(limit);
+ expect(result.body.data.beforeNextId).toEqual(nextId);
+ expect(result.body.data.nextId).toEqual(1);
+ expect(result.body.data.isLast).toEqual(true);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(5);
+ expect(result.body.data.data[1].id).toEqual(2);
+ expect(result.body.data.data[2].id).toEqual(1);
+
+ //검색조건이 맞는지
+ expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[2].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS);
+ expect(result.body.data.data[2].status).toEqual(GROUP_STATUS.PROGRESS);
+ });
+
+ test('모집게시글을 location 없이 조회하면 모든 status에 해당하고 검색 조건이 맞는 해당하는 게시글을 불러온다.', async () => {
+ // given
+ const categories = getGroupCategoryFixture();
+
+ const userRepository = dataSource.getRepository(User);
+ const user = getUserFixture({ id: 3 });
+ await userRepository.save(user);
+
+ const group1 = getGroupFixture(categories[1], {
+ id: 3,
+ maxCapacity: 5,
+ status: GROUP_STATUS.SUCCEED,
+ location: LOCATION.ONLINE,
+ });
+ const group2 = getGroupFixture(categories[3], {
+ id: 4,
+ status: GROUP_STATUS.PROGRESS,
+ location: LOCATION.SEOUL,
+ });
+ const group3 = getGroupFixture(categories[1], {
+ id: 5,
+ status: GROUP_STATUS.SUCCEED,
+ location: LOCATION.ONLINE,
+ });
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle1 = await getGroupArticleFixture(group1, {
+ id: 3,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle2 = await getGroupArticleFixture(group2, {
+ id: 4,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle3 = await getGroupArticleFixture(group3, {
+ id: 5,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ await groupArticleRepository.save([
+ groupArticle1,
+ groupArticle2,
+ groupArticle3,
+ ]);
+
+ const limit = 10;
+ const nextId = 50;
+ const category = CATEGORY.STUDY;
+ const location = LOCATION.ONLINE;
+ const status = '';
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ limit, nextId, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.limit).toEqual(limit);
+ expect(result.body.data.beforeNextId).toEqual(nextId);
+ expect(result.body.data.nextId).toEqual(1);
+ expect(result.body.data.isLast).toEqual(true);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(5);
+ expect(result.body.data.data[1].id).toEqual(3);
+ expect(result.body.data.data[2].id).toEqual(2);
+ expect(result.body.data.data[3].id).toEqual(1);
+
+ //검색조건이 맞는지
+ expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[2].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[3].category).toEqual(CATEGORY.STUDY);
+ expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[2].location).toEqual(LOCATION.ONLINE);
+ expect(result.body.data.data[3].location).toEqual(LOCATION.ONLINE);
+ });
+
+ test('모집게시글을 검색 조건 없이 조회하면 모든 게시글을 조회한다.', async () => {
+ // given
+ const categories = getGroupCategoryFixture();
+
+ const userRepository = dataSource.getRepository(User);
+ const user = getUserFixture({ id: 3 });
+ await userRepository.save(user);
+
+ const group1 = getGroupFixture(categories[1], {
+ id: 3,
+ maxCapacity: 5,
+ status: GROUP_STATUS.FAIL,
+ location: LOCATION.BUSAN,
+ });
+ const group2 = getGroupFixture(categories[3], {
+ id: 4,
+ status: GROUP_STATUS.PROGRESS,
+ location: LOCATION.SEOUL,
+ });
+ const group3 = getGroupFixture(categories[2], {
+ id: 5,
+ status: GROUP_STATUS.SUCCEED,
+ location: LOCATION.ONLINE,
+ });
+
+ const groupArticleRepository = dataSource.getRepository(GroupArticle);
+ const groupArticle1 = await getGroupArticleFixture(group1, {
+ id: 3,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle2 = await getGroupArticleFixture(group2, {
+ id: 4,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ const groupArticle3 = await getGroupArticleFixture(group3, {
+ id: 5,
+ user: new Promise((res) => res(user)),
+ userId: user.id,
+ });
+ await groupArticleRepository.save([
+ groupArticle1,
+ groupArticle2,
+ groupArticle3,
+ ]);
+
+ const limit = 10;
+ const nextId = 50;
+ const category = '';
+ const location = '';
+ const status = '';
+
+ // when
+ const result = await request(app.getHttpServer()).get(
+ url({ limit, nextId, category, location, status }),
+ );
+
+ // then
+ expect(result.status).toEqual(200);
+ expect(result.body.data.limit).toEqual(limit);
+ expect(result.body.data.beforeNextId).toEqual(nextId);
+ expect(result.body.data.nextId).toEqual(1);
+ expect(result.body.data.isLast).toEqual(true);
+
+ // 최신순인지
+ expect(result.body.data.data[0].id).toEqual(5);
+ expect(result.body.data.data[1].id).toEqual(4);
+ expect(result.body.data.data[2].id).toEqual(3);
+ expect(result.body.data.data[3].id).toEqual(2);
+ expect(result.body.data.data[4].id).toEqual(1);
+ });
+ });
+
+ describe('모집 게시글 삭제 DELETE /group-articles/:id', () => {
+ const url = (id: number) => `/v1/group-articles/${id}`;
+
+ test('모집게시글을 정상적으로 삭제하면 204 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .delete(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(204);
+ });
+
+ test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => {
+ // given
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer()).delete(
+ url(groupArticleId),
+ );
+
+ // then
+ expect(result.status).toEqual(401);
+ });
+
+ test('모집게시글을 삭제할 때 작성자가 아니라면 403 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 2 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 1;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .delete(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(403);
+ });
+
+ test('모집게시글을 삭제할 때 없는 게시물에 접근하면 404 코드를 던진다.', async () => {
+ // given
+ const jwtService = app.get(JwtTokenService);
+ const user = await dataSource.getRepository(User).findOneBy({ id: 1 });
+ const accessToken = jwtService.generateAccessToken(user);
+ const groupArticleId = 10000;
+
+ // when
+ const result = await request(app.getHttpServer())
+ .delete(url(groupArticleId))
+ .set({ Cookie: setCookie(accessToken.accessToken) });
+
+ // then
+ expect(result.status).toEqual(404);
+ });
+ });
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 816c612e..7ea03a2e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -22,6 +22,7 @@
"@tanstack/react-query-devtools": "^4.16.1",
"axios": "^1.1.3",
"browser-image-compression": "^2.0.0",
+ "framer-motion": "6.2.4",
"heic2any": "^0.0.3",
"next": "^12.3.3",
"next-seo": "^5.15.0",
@@ -17472,6 +17473,48 @@
"node": ">=0.10.0"
}
},
+ "node_modules/framer-motion": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.2.4.tgz",
+ "integrity": "sha512-1UfnSG4c4CefKft6QMYGx8AWt3TtaFoR/Ax4dkuDDD5BDDeIuUm7gesmJrF8GzxeX/i6fMm8+MEdPngUyPVdLA==",
+ "dependencies": {
+ "framesync": "6.0.1",
+ "hey-listen": "^1.0.8",
+ "popmotion": "11.0.3",
+ "style-value-types": "5.0.0",
+ "tslib": "^2.1.0"
+ },
+ "optionalDependencies": {
+ "@emotion/is-prop-valid": "^0.8.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.8 || ^17.0.0",
+ "react-dom": ">=16.8 || ^17.0.0"
+ }
+ },
+ "node_modules/framer-motion/node_modules/@emotion/is-prop-valid": {
+ "version": "0.8.8",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
+ "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
+ "optional": true,
+ "dependencies": {
+ "@emotion/memoize": "0.7.4"
+ }
+ },
+ "node_modules/framer-motion/node_modules/@emotion/memoize": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
+ "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
+ "optional": true
+ },
+ "node_modules/framesync": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz",
+ "integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -18295,6 +18338,11 @@
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.3.tgz",
"integrity": "sha512-1KG0LzZuIPiqyJjwLgGlgrgWd3UBwUE9g5+tOuHy8PbeH2hF0U4gc4ZWT4ChlCmcdISr1xVRimSehsTOPdRXnQ=="
},
+ "node_modules/hey-listen": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
+ "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="
+ },
"node_modules/hmac-drbg": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@@ -23366,6 +23414,17 @@
"node": ">=10"
}
},
+ "node_modules/popmotion": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz",
+ "integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==",
+ "dependencies": {
+ "framesync": "6.0.1",
+ "hey-listen": "^1.0.8",
+ "style-value-types": "5.0.0",
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/posix-character-classes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
@@ -26766,6 +26825,15 @@
"inline-style-parser": "0.1.1"
}
},
+ "node_modules/style-value-types": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz",
+ "integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==",
+ "dependencies": {
+ "hey-listen": "^1.0.8",
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.7.tgz",
@@ -42298,6 +42366,44 @@
"map-cache": "^0.2.2"
}
},
+ "framer-motion": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.2.4.tgz",
+ "integrity": "sha512-1UfnSG4c4CefKft6QMYGx8AWt3TtaFoR/Ax4dkuDDD5BDDeIuUm7gesmJrF8GzxeX/i6fMm8+MEdPngUyPVdLA==",
+ "requires": {
+ "@emotion/is-prop-valid": "^0.8.2",
+ "framesync": "6.0.1",
+ "hey-listen": "^1.0.8",
+ "popmotion": "11.0.3",
+ "style-value-types": "5.0.0",
+ "tslib": "^2.1.0"
+ },
+ "dependencies": {
+ "@emotion/is-prop-valid": {
+ "version": "0.8.8",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
+ "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
+ "optional": true,
+ "requires": {
+ "@emotion/memoize": "0.7.4"
+ }
+ },
+ "@emotion/memoize": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
+ "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
+ "optional": true
+ }
+ }
+ },
+ "framesync": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz",
+ "integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==",
+ "requires": {
+ "tslib": "^2.1.0"
+ }
+ },
"fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -42933,6 +43039,11 @@
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.3.tgz",
"integrity": "sha512-1KG0LzZuIPiqyJjwLgGlgrgWd3UBwUE9g5+tOuHy8PbeH2hF0U4gc4ZWT4ChlCmcdISr1xVRimSehsTOPdRXnQ=="
},
+ "hey-listen": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
+ "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="
+ },
"hmac-drbg": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@@ -46744,6 +46855,17 @@
"@babel/runtime": "^7.17.8"
}
},
+ "popmotion": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz",
+ "integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==",
+ "requires": {
+ "framesync": "6.0.1",
+ "hey-listen": "^1.0.8",
+ "style-value-types": "5.0.0",
+ "tslib": "^2.1.0"
+ }
+ },
"posix-character-classes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
@@ -49382,6 +49504,15 @@
"inline-style-parser": "0.1.1"
}
},
+ "style-value-types": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz",
+ "integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==",
+ "requires": {
+ "hey-listen": "^1.0.8",
+ "tslib": "^2.1.0"
+ }
+ },
"styled-jsx": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.7.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c142c06f..1843ac56 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -40,6 +40,7 @@
"@tanstack/react-query-devtools": "^4.16.1",
"axios": "^1.1.3",
"browser-image-compression": "^2.0.0",
+ "framer-motion": "6.2.4",
"heic2any": "^0.0.3",
"next": "^12.3.3",
"next-seo": "^5.15.0",
diff --git a/frontend/src/components/common/BrowserCheck/index.tsx b/frontend/src/components/common/BrowserCheck/index.tsx
deleted file mode 100644
index 6d4be8d7..00000000
--- a/frontend/src/components/common/BrowserCheck/index.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import { isChrome, isChromium } from 'react-device-detect';
-
-import AlertModal from '@components/common/AlertModal';
-
-const BrowserCheck = () => {
- const [modalOpen, setModalOpen] = useState(false);
-
- useEffect(() => {
- const browserChecked = localStorage.getItem('browser-checked');
- if (browserChecked) return;
- if (!(isChrome || isChromium)) {
- setModalOpen(true);
- }
- localStorage.setItem('browser-checked', 'true');
- }, []);
-
- return (
- <>
- setModalOpen(false)}
- />
- >
- );
-};
-
-export default BrowserCheck;
diff --git a/frontend/src/components/common/GroupArticleCard/index.tsx b/frontend/src/components/common/GroupArticleCard/index.tsx
index 1966516c..0f69d706 100644
--- a/frontend/src/components/common/GroupArticleCard/index.tsx
+++ b/frontend/src/components/common/GroupArticleCard/index.tsx
@@ -45,10 +45,11 @@ const GroupArticleCard = ({ article }: Props) => {
alt={'thumbnail-image'}
layout="fill"
objectFit="cover"
- sizes="(min-width: 800px) 300px,150px"
+ sizes="(min-width: 600px) 300px,150px"
placeholder="blur"
blurDataURL={blurUrl}
- style={{ transition: '0.3s ease-in-out' }}
+ style={{ transition: '0.2s ease-in-out' }}
+ priority
/>
diff --git a/frontend/src/components/common/NotificationToast/index.tsx b/frontend/src/components/common/NotificationToast/index.tsx
new file mode 100644
index 00000000..ab78f2b9
--- /dev/null
+++ b/frontend/src/components/common/NotificationToast/index.tsx
@@ -0,0 +1,19 @@
+import { useRouter } from 'next/router';
+
+import useNotificationEvent from '@hooks/useNotificationEvent';
+import { showToast } from '@utils/toast';
+
+const NotificationToast = () => {
+ const { isReady, pathname } = useRouter();
+
+ useNotificationEvent({
+ onNotification: (e) => {
+ showToast({ title: '알림 도착!', message: '알림 페이지에서 내용을 확인해보세요.' });
+ },
+ enabled: isReady && pathname !== '/notification',
+ });
+
+ return <>>;
+};
+
+export default NotificationToast;
diff --git a/frontend/src/components/notification/NotificationItem/NotificationItem.stories.tsx b/frontend/src/components/notification/NotificationItem/NotificationItem.stories.tsx
index 9637792c..6de62a13 100644
--- a/frontend/src/components/notification/NotificationItem/NotificationItem.stories.tsx
+++ b/frontend/src/components/notification/NotificationItem/NotificationItem.stories.tsx
@@ -1,5 +1,7 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
+import styled from '@emotion/styled';
+
import { Notification } from '@constants/notification';
import NotificationItem from '.';
@@ -46,3 +48,35 @@ GroupFail.args = {
createdAt: '2021-08-01T00:00:00.000Z',
},
};
+
+const PageWrapper = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ padding: 1.6rem;
+ gap: 1.6rem;
+`;
+
+const PageTemplate: ComponentStory = (args) => (
+
+
+
+
+
+
+
+
+);
+
+export const Collection = PageTemplate.bind({});
+Collection.args = {
+ notification: {
+ id: 1,
+ type: Notification.GROUP_FAILED,
+ title: '모임이 무산되었어요.',
+ subTitle: '캐럿스터디 - 인천',
+ groupArticleId: 3,
+ createdAt: '2021-08-01T00:00:00.000Z',
+ },
+};
diff --git a/frontend/src/components/notification/NotificationItem/index.tsx b/frontend/src/components/notification/NotificationItem/index.tsx
index 4e3e4c91..39367dad 100644
--- a/frontend/src/components/notification/NotificationItem/index.tsx
+++ b/frontend/src/components/notification/NotificationItem/index.tsx
@@ -1,6 +1,8 @@
import Link from 'next/link';
import { useState } from 'react';
+import { motion } from 'framer-motion';
+
import styled from '@emotion/styled';
import { ActionIcon, Text } from '@mantine/core';
import { IconX } from '@tabler/icons';
@@ -25,58 +27,65 @@ const NotificationItem = ({ notification }: Props) => {
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
return (
-
+ <>
deleteNotification(notification.id)}
onCancelButtonClick={() => setConfirmModalOpen(false)}
/>
-
-
-
-
-
-
-
- {title}
-
-
- {subTitle}
-
-
-
-
-
- setConfirmModalOpen(true)}
- >
-
-
-
- {dateTimeFormat(createdAt)}
-
-
-
+
+
+
+
+
+
+
+
+ {title}
+
+
+ {subTitle}
+
+
+
+
+
+ setConfirmModalOpen(true)}
+ >
+
+
+
+ {dateTimeFormat(createdAt)}
+
+
+
+ >
);
};
export default NotificationItem;
-const NotificationWrapper = styled.div`
- display: grid;
+const NotificationWrapper = styled(motion.div)`
align-items: center;
- grid-template-columns: 1fr 5rem;
+ display: flex;
gap: 1.6rem;
padding: 1.6rem;
width: 100%;
+ height: 100%;
border: 1px solid ${({ theme }) => theme.colors.gray[2]};
border-radius: 0.8rem;
`;
@@ -102,13 +111,15 @@ const TitleWrapper = styled.div`
flex-direction: column;
gap: 0.8rem;
overflow-x: hidden;
+ white-space: nowrap;
`;
const AsideSection = styled.div`
height: 100%;
display: flex;
+ flex: 1;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
- min-width: 4rem;
+ white-space: nowrap;
`;
diff --git a/frontend/src/hooks/queries/useFetchGroupArticles.ts b/frontend/src/hooks/queries/useFetchGroupArticles.ts
index b81c10c4..3957d0d9 100644
--- a/frontend/src/hooks/queries/useFetchGroupArticles.ts
+++ b/frontend/src/hooks/queries/useFetchGroupArticles.ts
@@ -10,9 +10,8 @@ import { ArticlePreviewType } from '@typings/types';
import { clientAxios } from '@utils/commonAxios';
interface ArticlePagingData {
- totalPage: number;
- currentPage: number;
- countPerPage: number;
+ isLast: boolean;
+ nextId: number;
data: ArticlePreviewType[];
}
interface ArticleResponseType {
@@ -22,7 +21,7 @@ interface ArticleResponseType {
}
export const getGroupArticles = async (
- currentPage: number,
+ nextId: number,
category: Category,
location: Location,
filterProgress: boolean
@@ -30,8 +29,8 @@ export const getGroupArticles = async (
const status = filterProgress ? ArticleStatus.PROGRESS : null;
const {
data: { data },
- } = await clientAxios('/v1/group-articles/search', {
- params: { category, location, status, currentPage, countPerPage: 8 },
+ } = await clientAxios('v2/group-articles/search', {
+ params: { category, location, status, nextId, limit: 8 },
});
return data;
};
@@ -43,10 +42,9 @@ const useFetchGroupArticles = (
) => {
const { data, ...rest } = useAuthInfiniteQuery(
['articles', category, location, filterProgress],
- ({ pageParam = 1 }) => getGroupArticles(pageParam, category, location, filterProgress),
+ ({ pageParam }) => getGroupArticles(pageParam, category, location, filterProgress),
{
- getNextPageParam: (lastPage) =>
- lastPage.data.length === 0 ? undefined : lastPage.currentPage + 1,
+ getNextPageParam: (lastPage) => (lastPage.isLast ? undefined : lastPage.nextId),
}
);
diff --git a/frontend/src/hooks/queries/useFetchNotifications.ts b/frontend/src/hooks/queries/useFetchNotifications.ts
index a9003568..0419f8e4 100644
--- a/frontend/src/hooks/queries/useFetchNotifications.ts
+++ b/frontend/src/hooks/queries/useFetchNotifications.ts
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { AxiosError } from 'axios';
import useAuthInfiniteQuery from '@hooks/useAuthInfiniteQuery';
+import useNotificationEvent from '@hooks/useNotificationEvent';
import { NotificationType } from '@typings/types';
import { clientAxios } from '@utils/commonAxios';
@@ -29,14 +30,13 @@ const getNotifications = async (currentPage: number) => {
};
const useFetchNotifications = () => {
- const { data, ...queryResult } = useAuthInfiniteQuery<
+ const { data, refetch, ...queryResult } = useAuthInfiniteQuery<
NotificationPagingData,
AxiosError,
NotificationPagingData
>(['notifications'], ({ pageParam = 1 }) => getNotifications(pageParam), {
getNextPageParam: (lastPage) =>
lastPage.data.length === 0 ? undefined : lastPage.currentPage + 1,
- refetchInterval: 3000,
});
const notifications = useMemo(
@@ -44,6 +44,12 @@ const useFetchNotifications = () => {
[data]
);
+ useNotificationEvent({
+ onNotification: (e) => {
+ void refetch();
+ },
+ });
+
return { data: notifications, ...queryResult };
};
diff --git a/frontend/src/hooks/useNotificationEvent.ts b/frontend/src/hooks/useNotificationEvent.ts
new file mode 100644
index 00000000..d915d887
--- /dev/null
+++ b/frontend/src/hooks/useNotificationEvent.ts
@@ -0,0 +1,36 @@
+import { useEffect } from 'react';
+
+import useFetchMyInfo from '@hooks/queries/useFetchMyInfo';
+
+interface Props {
+ onNotification: (e: MessageEvent) => void;
+ enabled?: boolean;
+}
+
+const useNotificationEvent = ({ onNotification, enabled = true }: Props) => {
+ const { data: myData } = useFetchMyInfo();
+ useEffect(() => {
+ if (!myData) return;
+ let sse: EventSource | null = null;
+ try {
+ sse = new EventSource(`${process.env.NEXT_PUBLIC_API_URL}/v1/sse`, {
+ withCredentials: true,
+ });
+
+ sse.addEventListener('NOTIFICATION', (e) => {
+ if (enabled) onNotification(e);
+ });
+
+ sse.onerror = (event) => {
+ sse.close();
+ };
+ } catch (err) {
+ throw Error('Server Sent Event Error');
+ }
+ return () => {
+ if (sse) sse.close();
+ };
+ }, [myData, onNotification, enabled]);
+};
+
+export default useNotificationEvent;
diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx
index d7da2a2f..7062fc12 100644
--- a/frontend/src/pages/_app.tsx
+++ b/frontend/src/pages/_app.tsx
@@ -8,11 +8,11 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { RecoilRoot } from 'recoil';
import { v4 as uuid } from 'uuid';
-import BrowserCheck from '@components/common/BrowserCheck';
import ApiErrorBoundary from '@components/common/ErrorBoundary/ApiErrorBoundary';
import AuthErrorBoundary from '@components/common/ErrorBoundary/AuthErrorBoundary';
import ErrorBoundary from '@components/common/ErrorBoundary/ErrorBoundary';
import LoginRedirect from '@components/common/LoginRedirect';
+import NotificationToast from '@components/common/NotificationToast';
import RouterTransition from '@components/common/RouterTransition';
import ScrollHandler from '@components/common/ScrollHandler';
import initMockApi from '@mocks/.';
@@ -61,7 +61,7 @@ export default function App({ Component, pageProps }: AppProps<{ dehydratedState
-
+
diff --git a/frontend/src/pages/article/[id].tsx b/frontend/src/pages/article/[id].tsx
index b74bc53a..e67143a2 100644
--- a/frontend/src/pages/article/[id].tsx
+++ b/frontend/src/pages/article/[id].tsx
@@ -167,7 +167,11 @@ const ArticleDetail = () => {
0 && { before: true })}
components={comments.map((comment) => (
-
+ setAddedComment(null)}
+ />
))}
/>
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 0ea43b73..18ef8e77 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -33,13 +33,8 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
}
const queryClient = new QueryClient();
- await queryClient.prefetchInfiniteQuery(
- ['articles', null, null, false],
- ({ pageParam = 1 }) => getGroupArticles(pageParam, null, null, false),
- {
- getNextPageParam: (lastPage) =>
- lastPage.totalPage === lastPage.currentPage ? undefined : lastPage.currentPage + 1,
- }
+ await queryClient.prefetchInfiniteQuery(['articles', null, null, false], ({ pageParam }) =>
+ getGroupArticles(pageParam, null, null, false)
);
return { props: { dehydratedState: JSON.parse(JSON.stringify(dehydrate(queryClient))) } };
};