diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index aaef3a5..741cec5 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -16,6 +16,12 @@ jobs: test: runs-on: ubuntu-latest + services: + redis: + image: redis:latest + ports: + - 6380:6379 + steps: - name: Check out uses: actions/checkout@v3 @@ -26,6 +32,16 @@ jobs: distribution: 'temurin' java-version: '17' + - name: Copy application.yml + run: | + mkdir ./src/main/resources + touch ./src/main/resources/application.yml + echo "${{ secrets.APPLICATION }}" > ./src/main/resources/application.yml + touch ./src/main/resources/application-test.yml + echo "${{ secrets.APPLICATION_TEST }}" > ./src/main/resources/application-test.yml + touch ./src/main/resources/application-release.yml + echo "${{ secrets.APPLICATION_RELEASE }}" > ./src/main/resources/application-release.yml + - name: Cache Gradle packages uses: actions/cache@v3 with: @@ -55,5 +71,5 @@ jobs: title: Test Coverage Report paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml token: ${{ secrets.ACCESS_TOKEN }} - min-coverage-overall: 80 - min-coverage-changed-files: 50 + min-coverage-overall: 70 + min-coverage-changed-files: 40 diff --git a/build.gradle b/build.gradle index 279ab39..0876b4b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,38 +1,86 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.3' - id 'io.spring.dependency-management' version '1.1.4' + id 'java' + id 'org.springframework.boot' version '3.2.3' + id 'io.spring.dependency-management' version '1.1.4' + id 'org.asciidoctor.jvm.convert' version '3.3.2' + id 'jacoco' } group = 'site' version = '0.0.1-SNAPSHOT' java { - sourceCompatibility = '17' + sourceCompatibility = '17' } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } + + asciidoctorExt } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() + + finalizedBy jacocoTestReport +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +asciidoctor { + inputs.dir snippetsDir + configurations 'asciidoctorExt' + + sources { + include("**/index.adoc") + } + + baseDirFollowsSourceFile() + dependsOn test +} + +bootJar { + dependsOn asciidoctor + + from("${asciidoctor.outputDir}") { + into 'static/docs' + } +} + +jacoco { + toolVersion = "0.8.8" +} + +jacocoTestReport { + dependsOn test + + reports { + xml.required = true + html.required = true + } + + finalizedBy 'jacocoTestCoverageVerification' } jar { - enabled = false + enabled = false } diff --git a/src/docs/asciidoc/api/room.adoc b/src/docs/asciidoc/api/room.adoc new file mode 100644 index 0000000..9248ebd --- /dev/null +++ b/src/docs/asciidoc/api/room.adoc @@ -0,0 +1,45 @@ +[[Room-API]] +== Room API + +[[create-room-success]] +=== 방 생성 성공 + +==== HTTP Request + +include::{snippets}/create-room-success/http-request.adoc[] +include::{snippets}/create-room-success/request-fields.adoc[] + +==== HTTP Response + +include::{snippets}/create-room-success/http-response.adoc[] +include::{snippets}/create-room-success/response-fields.adoc[] + +{nbsp} + +[[create-room-fail-room-setting-error]] +=== 방 생성 실패: 요청 데이터 오류가 발생했습니다 + +==== HTTP Request + +include::{snippets}/create-room-fail-room-setting-error/http-request.adoc[] +include::{snippets}/create-room-fail-room-setting-error/request-fields.adoc[] + +==== HTTP Response + +include::{snippets}/create-room-fail-room-setting-error/http-response.adoc[] +include::{snippets}/create-room-fail-room-setting-error/response-fields.adoc[] + +{nbsp} + +[[create-room-fail-single-room-participant-violation]] +=== 방 생성 실패: 다수의 방에 참가할 수 없습니다 + +==== HTTP Request + +include::{snippets}/create-room-fail-single-room-participant-violation/http-request.adoc[] +include::{snippets}/create-room-fail-single-room-participant-violation/request-fields.adoc[] + +==== HTTP Response + +include::{snippets}/create-room-fail-single-room-participant-violation/http-response.adoc[] +include::{snippets}/create-room-fail-single-room-participant-violation/response-fields.adoc[] diff --git a/src/docs/asciidoc/docinfo.html b/src/docs/asciidoc/docinfo.html new file mode 100644 index 0000000..f979079 --- /dev/null +++ b/src/docs/asciidoc/docinfo.html @@ -0,0 +1,79 @@ + + + + + + + + + diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 0000000..93638dc --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,14 @@ +ifndef::snippets[] +:snippets: ../../build/generated-snippets +endif::[] + += YouTogether API Document +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 2 +:sectlinks: +:docinfo: shared + +include::api/room.adoc[] diff --git a/src/main/java/site/youtogether/config/PropertiesConfig.java b/src/main/java/site/youtogether/config/PropertiesConfig.java new file mode 100644 index 0000000..70a1468 --- /dev/null +++ b/src/main/java/site/youtogether/config/PropertiesConfig.java @@ -0,0 +1,14 @@ +package site.youtogether.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import site.youtogether.config.property.CookieProperties; + +@Configuration +@EnableConfigurationProperties(value = { + CookieProperties.class +}) +public class PropertiesConfig { + +} diff --git a/src/main/java/site/youtogether/config/RedisConfig.java b/src/main/java/site/youtogether/config/RedisConfig.java new file mode 100644 index 0000000..b731bb3 --- /dev/null +++ b/src/main/java/site/youtogether/config/RedisConfig.java @@ -0,0 +1,38 @@ +package site.youtogether.config; + +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort()); + redisStandaloneConfiguration.setPassword(redisProperties.getPassword()); + + return new LettuceConnectionFactory(redisStandaloneConfiguration); + } + + @Bean + public RedisTemplate redisStringTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } + +} diff --git a/src/main/java/site/youtogether/config/WebConfig.java b/src/main/java/site/youtogether/config/WebConfig.java new file mode 100644 index 0000000..f49cd9d --- /dev/null +++ b/src/main/java/site/youtogether/config/WebConfig.java @@ -0,0 +1,23 @@ +package site.youtogether.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import lombok.RequiredArgsConstructor; +import site.youtogether.resolver.AddressArgumentResolver; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final AddressArgumentResolver addressArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(addressArgumentResolver); + } + +} diff --git a/src/main/java/site/youtogether/config/property/CookieProperties.java b/src/main/java/site/youtogether/config/property/CookieProperties.java new file mode 100644 index 0000000..26671c3 --- /dev/null +++ b/src/main/java/site/youtogether/config/property/CookieProperties.java @@ -0,0 +1,19 @@ +package site.youtogether.config.property; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@ConfigurationProperties("cookie") +@RequiredArgsConstructor +@Getter +public class CookieProperties { + + private final String name; + private final String domain; + private final String path; + private final String sameSite; + private final int expiry; + +} diff --git a/src/main/java/site/youtogether/exception/CustomException.java b/src/main/java/site/youtogether/exception/CustomException.java new file mode 100644 index 0000000..d491267 --- /dev/null +++ b/src/main/java/site/youtogether/exception/CustomException.java @@ -0,0 +1,17 @@ +package site.youtogether.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final HttpStatus status; + + public CustomException(ErrorType errorType) { + super(errorType.getMessage()); + this.status = errorType.getStatus(); + } + +} diff --git a/src/main/java/site/youtogether/exception/ErrorType.java b/src/main/java/site/youtogether/exception/ErrorType.java new file mode 100644 index 0000000..4f60eb3 --- /dev/null +++ b/src/main/java/site/youtogether/exception/ErrorType.java @@ -0,0 +1,21 @@ +package site.youtogether.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public enum ErrorType { + + // Room + SINGLE_ROOM_PARTICIPATION_VIOLATION(HttpStatus.BAD_REQUEST, "하나의 방에만 참가할 수 있습니다"); + + private final HttpStatus status; + private final String message; + + ErrorType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + +} diff --git a/src/main/java/site/youtogether/exception/GlobalExceptionHandler.java b/src/main/java/site/youtogether/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..38f01bb --- /dev/null +++ b/src/main/java/site/youtogether/exception/GlobalExceptionHandler.java @@ -0,0 +1,51 @@ +package site.youtogether.exception; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import site.youtogether.util.api.ApiResponse; +import site.youtogether.util.api.ResponseResult; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BindException.class) + public ResponseEntity> handleBindException(BindException exception) { + return ResponseEntity.badRequest() + .body(ApiResponse.of(HttpStatus.BAD_REQUEST, ResponseResult.EXCEPTION_OCCURRED, + exception.getBindingResult().getFieldErrors().stream() + .collect(Collectors.groupingBy(FieldError::getField)) + .entrySet().stream() + .map(error -> { + Map fieldError = new LinkedHashMap<>(); + fieldError.put("type", error.getKey()); + fieldError.put("message", error.getValue().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", "))); + return fieldError; + }) + ) + ); + } + + @ExceptionHandler(CustomException.class) + public ResponseEntity> handleCustomException(CustomException customException) { + Map error = new LinkedHashMap<>(2); + error.put("type", customException.getClass().getSimpleName()); + error.put("message", customException.getMessage()); + + return ResponseEntity.badRequest() + .body(ApiResponse.of(customException.getStatus(), ResponseResult.EXCEPTION_OCCURRED, List.of(error))); + } + +} diff --git a/src/main/java/site/youtogether/exception/room/SingleRoomParticipationViolationException.java b/src/main/java/site/youtogether/exception/room/SingleRoomParticipationViolationException.java new file mode 100644 index 0000000..47834bf --- /dev/null +++ b/src/main/java/site/youtogether/exception/room/SingleRoomParticipationViolationException.java @@ -0,0 +1,12 @@ +package site.youtogether.exception.room; + +import site.youtogether.exception.CustomException; +import site.youtogether.exception.ErrorType; + +public class SingleRoomParticipationViolationException extends CustomException { + + public SingleRoomParticipationViolationException() { + super(ErrorType.SINGLE_ROOM_PARTICIPATION_VIOLATION); + } + +} diff --git a/src/main/java/site/youtogether/resolver/Address.java b/src/main/java/site/youtogether/resolver/Address.java new file mode 100644 index 0000000..acc6793 --- /dev/null +++ b/src/main/java/site/youtogether/resolver/Address.java @@ -0,0 +1,11 @@ +package site.youtogether.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Address { +} diff --git a/src/main/java/site/youtogether/resolver/AddressArgumentResolver.java b/src/main/java/site/youtogether/resolver/AddressArgumentResolver.java new file mode 100644 index 0000000..3c58b8e --- /dev/null +++ b/src/main/java/site/youtogether/resolver/AddressArgumentResolver.java @@ -0,0 +1,32 @@ +package site.youtogether.resolver; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import jakarta.servlet.http.HttpServletRequest; + +@Component +public class AddressArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasAddressAnnotation = parameter.hasParameterAnnotation(Address.class); + boolean isStringType = String.class.isAssignableFrom(parameter.getParameterType()); + + return hasAddressAnnotation && isStringType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + + assert request != null; + return request.getRemoteAddr(); + } + +} diff --git a/src/main/java/site/youtogether/room/Room.java b/src/main/java/site/youtogether/room/Room.java new file mode 100644 index 0000000..02f943a --- /dev/null +++ b/src/main/java/site/youtogether/room/Room.java @@ -0,0 +1,43 @@ +package site.youtogether.room; + +import static site.youtogether.util.AppConstants.*; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import site.youtogether.user.User; +import site.youtogether.util.RandomUtil; + +@RedisHash(value = "room") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Room { + + @Id + private String code; + + private int capacity; + private String title; + private String password; + private User host; + private Map participants = new HashMap<>(10); + + @Builder + public Room(String title, int capacity, String password, User host) { + this.code = RandomUtil.generateRandomCode(ROOM_CODE_LENGTH); + this.capacity = capacity; + this.title = title; + this.password = password; + this.host = host; + + participants.put(host.getSessionCode(), host); + } + +} diff --git a/src/main/java/site/youtogether/room/application/RoomService.java b/src/main/java/site/youtogether/room/application/RoomService.java new file mode 100644 index 0000000..fa0333d --- /dev/null +++ b/src/main/java/site/youtogether/room/application/RoomService.java @@ -0,0 +1,43 @@ +package site.youtogether.room.application; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import site.youtogether.room.Room; +import site.youtogether.room.dto.RoomCode; +import site.youtogether.room.dto.RoomSettings; +import site.youtogether.room.infrastructure.RoomStorage; +import site.youtogether.user.Role; +import site.youtogether.user.User; +import site.youtogether.user.infrastructure.UserStorage; +import site.youtogether.util.RandomUtil; + +@Service +@RequiredArgsConstructor +public class RoomService { + + private final RoomStorage roomStorage; + private final UserStorage userStorage; + + public RoomCode create(String sessionCode, String address, RoomSettings roomSettings) { + User host = User.builder() + .sessionCode(sessionCode) + .address(address) + .nickname(RandomUtil.generateUserNickname()) + .role(Role.HOST) + .build(); + + Room room = Room.builder() + .capacity(roomSettings.getCapacity()) + .title(roomSettings.getTitle()) + .password(roomSettings.getPassword()) + .host(host) + .build(); + + userStorage.save(host); + roomStorage.save(room); + + return new RoomCode(room); + } + +} diff --git a/src/main/java/site/youtogether/room/dto/RoomCode.java b/src/main/java/site/youtogether/room/dto/RoomCode.java new file mode 100644 index 0000000..19bd203 --- /dev/null +++ b/src/main/java/site/youtogether/room/dto/RoomCode.java @@ -0,0 +1,17 @@ +package site.youtogether.room.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import site.youtogether.room.Room; + +@AllArgsConstructor +@Getter +public class RoomCode { + + private final String roomCode; + + public RoomCode(Room room) { + this.roomCode = room.getCode(); + } + +} diff --git a/src/main/java/site/youtogether/room/dto/RoomSettings.java b/src/main/java/site/youtogether/room/dto/RoomSettings.java new file mode 100644 index 0000000..c9f26ec --- /dev/null +++ b/src/main/java/site/youtogether/room/dto/RoomSettings.java @@ -0,0 +1,33 @@ +package site.youtogether.room.dto; + +import org.hibernate.validator.constraints.Range; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +public class RoomSettings { + + @NotBlank(message = "공백이 아닌 문자를 1개 이상 입력해 주세요") + @Size(min = 1, max = 30, message = "제목은 {min}자 이상 {max}자 이하로 입력해 주세요") + private String title; + + @Range(min = 2, max = 10, message = "정원은 {min}명 이상 {max}명 이하로 입력해 주세요") + private int capacity; + + @Pattern(regexp = "^[0-9a-zA-Z]{5,10}$", message = "비밀번호는 5자 이상 10자 이하의 영문 또는 숫자로 입력해 주세요") + private String password; + + @Builder + public RoomSettings(String title, int capacity, String password) { + this.title = title; + this.capacity = capacity; + this.password = password; + } + +} diff --git a/src/main/java/site/youtogether/room/infrastructure/RoomStorage.java b/src/main/java/site/youtogether/room/infrastructure/RoomStorage.java new file mode 100644 index 0000000..8ab5572 --- /dev/null +++ b/src/main/java/site/youtogether/room/infrastructure/RoomStorage.java @@ -0,0 +1,11 @@ +package site.youtogether.room.infrastructure; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import site.youtogether.room.Room; + +@Repository +public interface RoomStorage extends CrudRepository { + +} diff --git a/src/main/java/site/youtogether/room/presentation/RoomController.java b/src/main/java/site/youtogether/room/presentation/RoomController.java new file mode 100644 index 0000000..8e2ba32 --- /dev/null +++ b/src/main/java/site/youtogether/room/presentation/RoomController.java @@ -0,0 +1,64 @@ +package site.youtogether.room.presentation; + +import static site.youtogether.util.AppConstants.*; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import site.youtogether.config.property.CookieProperties; +import site.youtogether.exception.room.SingleRoomParticipationViolationException; +import site.youtogether.resolver.Address; +import site.youtogether.room.application.RoomService; +import site.youtogether.room.dto.RoomCode; +import site.youtogether.room.dto.RoomSettings; +import site.youtogether.util.RandomUtil; +import site.youtogether.util.api.ApiResponse; +import site.youtogether.util.api.ResponseResult; + +@RestController +@RequiredArgsConstructor +public class RoomController { + + private final CookieProperties cookieProperties; + private final RoomService roomService; + + @PostMapping("/rooms") + public ResponseEntity> createRoom(@CookieValue(value = SESSION_COOKIE_NAME, required = false) Cookie sessionCookie, + @Address String address, @Valid @RequestBody RoomSettings roomSettings, HttpServletResponse response) { + // Check if a session cookie already exists. + if (sessionCookie != null) { + throw new SingleRoomParticipationViolationException(); + } + + // Generate a new session code and set it as a cookie. + ResponseCookie cookie = ResponseCookie.from(cookieProperties.getName(), RandomUtil.generateRandomCode(SESSION_CODE_LENGTH)) + .domain(cookieProperties.getDomain()) + .path(cookieProperties.getPath()) + .sameSite(cookieProperties.getSameSite()) + .maxAge(cookieProperties.getExpiry()) + .httpOnly(true) + .secure(true) + .build(); + + // Create a new room with the generated session code. + RoomCode roomCode = roomService.create(cookie.getValue(), address, roomSettings); + + // Add the cookie to the response header. + response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + // Return a response indicating successful room creation. + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(ResponseResult.ROOM_CREATION_SUCCESS, roomCode)); + } + +} diff --git a/src/main/java/site/youtogether/user/Role.java b/src/main/java/site/youtogether/user/Role.java new file mode 100644 index 0000000..a1dc456 --- /dev/null +++ b/src/main/java/site/youtogether/user/Role.java @@ -0,0 +1,7 @@ +package site.youtogether.user; + +public enum Role { + + HOST, MANAGER, EDITOR, GUEST, VIEWER + +} diff --git a/src/main/java/site/youtogether/user/User.java b/src/main/java/site/youtogether/user/User.java new file mode 100644 index 0000000..93f92e4 --- /dev/null +++ b/src/main/java/site/youtogether/user/User.java @@ -0,0 +1,28 @@ +package site.youtogether.user; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import lombok.Builder; +import lombok.Getter; + +@RedisHash(value = "user") +@Getter +public class User { + + @Id + private final String sessionCode; + + private final String address; + private final String nickname; + private final Role role; + + @Builder + public User(String sessionCode, String address, String nickname, Role role) { + this.sessionCode = sessionCode; + this.address = address; + this.nickname = nickname; + this.role = role; + } + +} diff --git a/src/main/java/site/youtogether/user/infrastructure/UserStorage.java b/src/main/java/site/youtogether/user/infrastructure/UserStorage.java new file mode 100644 index 0000000..d3b797a --- /dev/null +++ b/src/main/java/site/youtogether/user/infrastructure/UserStorage.java @@ -0,0 +1,11 @@ +package site.youtogether.user.infrastructure; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import site.youtogether.user.User; + +@Repository +public interface UserStorage extends CrudRepository { + +} diff --git a/src/main/java/site/youtogether/util/AppConstants.java b/src/main/java/site/youtogether/util/AppConstants.java new file mode 100644 index 0000000..463cd5c --- /dev/null +++ b/src/main/java/site/youtogether/util/AppConstants.java @@ -0,0 +1,16 @@ +package site.youtogether.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AppConstants { + + public static final String PARTICIPANTS_KEY = "participants"; + public static final String ROOM_KEY_PREFIX = "room:"; + public static final String USER_KEY_PREFIX = "user:"; + public static final String SESSION_COOKIE_NAME = "YTSession"; + public static final int ROOM_CODE_LENGTH = 10; + public static final int SESSION_CODE_LENGTH = 20; + +} diff --git a/src/main/java/site/youtogether/util/RandomUtil.java b/src/main/java/site/youtogether/util/RandomUtil.java new file mode 100644 index 0000000..b072174 --- /dev/null +++ b/src/main/java/site/youtogether/util/RandomUtil.java @@ -0,0 +1,40 @@ +package site.youtogether.util; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RandomUtil { + + /** + * generate random code + * using a-z A-Z 0-9 + */ + public static String generateRandomCode(int length) { + String randomString = UUID.randomUUID().toString().replaceAll("-", ""); + + return randomString.substring(0, length); + } + + /** + * generate random user nickname + * sample list size is 20 + */ + public static String generateUserNickname() { + List samples = List.of( + "MysticTiger", "SilverPhoenix", "ElectricWanderer", "CrimsonDragon", "EmeraldSpecter", + "MidnightRider", "VelvetWhisperer", "CosmicStrider", "SolarGoddess", "ArcticShadow", + "EnigmaticSphinx", "ScarletSorcerer", "CelestialWatcher", "LunarJester", "SapphireDreamer", + "GoldenGlider", "CrimsonFalcon", "EchoingWhisper", "EmberPhoenix", "RadiantRebel" + ); + + int randomIndex = ThreadLocalRandom.current().nextInt(samples.size()); + + return samples.get(randomIndex); + } + +} diff --git a/src/main/java/site/youtogether/util/api/ApiResponse.java b/src/main/java/site/youtogether/util/api/ApiResponse.java new file mode 100644 index 0000000..fc58825 --- /dev/null +++ b/src/main/java/site/youtogether/util/api/ApiResponse.java @@ -0,0 +1,34 @@ +package site.youtogether.util.api; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public class ApiResponse { + + private final int code; + private final String status; + private final String result; + private final T data; + + public ApiResponse(HttpStatus status, ResponseResult result, T data) { + this.code = status.value(); + this.status = status.getReasonPhrase(); + this.result = result.getDescription(); + this.data = data; + } + + public static ApiResponse of(HttpStatus status, ResponseResult result, T data) { + return new ApiResponse<>(status, result, data); + } + + public static ApiResponse ok(ResponseResult result, T data) { + return new ApiResponse<>(HttpStatus.OK, result, data); + } + + public static ApiResponse created(ResponseResult result, T data) { + return new ApiResponse<>(HttpStatus.CREATED, result, data); + } + +} diff --git a/src/main/java/site/youtogether/util/api/ResponseResult.java b/src/main/java/site/youtogether/util/api/ResponseResult.java new file mode 100644 index 0000000..c615ec1 --- /dev/null +++ b/src/main/java/site/youtogether/util/api/ResponseResult.java @@ -0,0 +1,20 @@ +package site.youtogether.util.api; + +import lombok.Getter; + +@Getter +public enum ResponseResult { + + // Common + EXCEPTION_OCCURRED("예외가 발생했습니다"), + + // Room + ROOM_CREATION_SUCCESS("방 생성에 성공했습니다"); + + private final String description; + + ResponseResult(String description) { + this.description = description; + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/test/java/site/youtogether/IntegrationTestSupport.java b/src/test/java/site/youtogether/IntegrationTestSupport.java new file mode 100644 index 0000000..3c28942 --- /dev/null +++ b/src/test/java/site/youtogether/IntegrationTestSupport.java @@ -0,0 +1,10 @@ +package site.youtogether; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public abstract class IntegrationTestSupport { + +} diff --git a/src/test/java/site/youtogether/RestDocsSupport.java b/src/test/java/site/youtogether/RestDocsSupport.java new file mode 100644 index 0000000..ff65587 --- /dev/null +++ b/src/test/java/site/youtogether/RestDocsSupport.java @@ -0,0 +1,36 @@ +package site.youtogether; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import site.youtogether.config.PropertiesConfig; +import site.youtogether.config.property.CookieProperties; +import site.youtogether.room.application.RoomService; +import site.youtogether.room.presentation.RoomController; + +@WebMvcTest(controllers = { + RoomController.class +}) +@AutoConfigureRestDocs +@Import(PropertiesConfig.class) +public abstract class RestDocsSupport { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @Autowired + protected CookieProperties cookieProperties; + + @MockBean + protected RoomService roomService; + +} diff --git a/src/test/java/site/youtogether/YouTogetherApplicationTests.java b/src/test/java/site/youtogether/YouTogetherApplicationTests.java deleted file mode 100644 index fd8b01b..0000000 --- a/src/test/java/site/youtogether/YouTogetherApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package site.youtogether; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class YouTogetherApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/site/youtogether/room/application/RoomServiceTest.java b/src/test/java/site/youtogether/room/application/RoomServiceTest.java new file mode 100644 index 0000000..7a8ac5a --- /dev/null +++ b/src/test/java/site/youtogether/room/application/RoomServiceTest.java @@ -0,0 +1,69 @@ +package site.youtogether.room.application; + +import static org.assertj.core.api.Assertions.*; +import static site.youtogether.util.AppConstants.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import site.youtogether.IntegrationTestSupport; +import site.youtogether.room.Room; +import site.youtogether.room.dto.RoomCode; +import site.youtogether.room.dto.RoomSettings; +import site.youtogether.room.infrastructure.RoomStorage; +import site.youtogether.user.Role; +import site.youtogether.user.User; +import site.youtogether.user.infrastructure.UserStorage; + +class RoomServiceTest extends IntegrationTestSupport { + + @Autowired + private RoomService roomService; + + @Autowired + private RoomStorage roomStorage; + + @Autowired + private UserStorage userStorage; + + @BeforeEach + void clean() { + roomStorage.deleteAll(); + userStorage.deleteAll(); + } + + @Test + @DisplayName("새로운 방과 해당 방의 HOST를 생성할 수 있다") + void createSuccess() { + // given + String sessionCode = "7644a835e52e45dfa385"; + String address = "127.0.0.1"; + RoomSettings roomSettings = RoomSettings.builder() + .capacity(10) + .title("재밌는 쇼츠 같이 보기") + .password(null) + .build(); + + // when + RoomCode roomCode = roomService.create(sessionCode, address, roomSettings); + + // then + Room room = roomStorage.findById(roomCode.getRoomCode()).get(); + User user = userStorage.findById(sessionCode).get(); + + assertThat(roomCode.getRoomCode()).hasSize(ROOM_CODE_LENGTH); + assertThat(roomCode.getRoomCode()).isEqualTo(room.getCode()); + assertThat(room.getCapacity()).isEqualTo(10); + assertThat(room.getTitle()).isEqualTo("재밌는 쇼츠 같이 보기"); + assertThat(room.getPassword()).isNull(); + assertThat(room.getHost().getSessionCode()).isEqualTo(sessionCode); + assertThat(room.getParticipants()).hasSize(1); + assertThat(user.getSessionCode()).isEqualTo(sessionCode); + assertThat(user.getAddress()).isEqualTo(address); + assertThat(user.getNickname()).isNotBlank(); + assertThat(user.getRole()).isEqualTo(Role.HOST); + } + +} diff --git a/src/test/java/site/youtogether/room/presentation/RoomControllerTest.java b/src/test/java/site/youtogether/room/presentation/RoomControllerTest.java new file mode 100644 index 0000000..e1bf9f2 --- /dev/null +++ b/src/test/java/site/youtogether/room/presentation/RoomControllerTest.java @@ -0,0 +1,167 @@ +package site.youtogether.room.presentation; + +import static org.mockito.BDDMockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static site.youtogether.exception.ErrorType.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; + +import jakarta.servlet.http.Cookie; +import site.youtogether.RestDocsSupport; +import site.youtogether.exception.room.SingleRoomParticipationViolationException; +import site.youtogether.room.dto.RoomCode; +import site.youtogether.room.dto.RoomSettings; +import site.youtogether.util.api.ResponseResult; + +class RoomControllerTest extends RestDocsSupport { + + @Test + @DisplayName("방 생성 성공") + void createRoomSuccess() throws Exception { + // given + // Setting up request data for creating a room + RoomSettings roomSettings = RoomSettings.builder() + .capacity(10) + .title("재밌는 쇼츠 같이 보기") + .password(null) + .build(); + + // Setting up response data for the created room + RoomCode roomCode = new RoomCode("1e7050f7d7"); + given(roomService.create(anyString(), anyString(), any(RoomSettings.class))) + .willReturn(roomCode); + + // when / then + String cookieName = cookieProperties.getName(); + + mockMvc.perform(post("/rooms") + .content(objectMapper.writeValueAsString(roomSettings)) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(cookie().exists(cookieName)) + .andExpect(cookie().domain(cookieName, cookieProperties.getDomain())) + .andExpect(cookie().path(cookieName, cookieProperties.getPath())) + .andExpect(cookie().sameSite(cookieName, cookieProperties.getSameSite())) + .andExpect(cookie().maxAge(cookieName, cookieProperties.getExpiry())) + .andExpect(cookie().httpOnly(cookieName, true)) + .andExpect(cookie().secure(cookieName, true)) + .andExpect(jsonPath("$.code").value(HttpStatus.CREATED.value())) + .andExpect(jsonPath("$.status").value(HttpStatus.CREATED.getReasonPhrase())) + .andExpect(jsonPath("$.result").value(ResponseResult.ROOM_CREATION_SUCCESS.getDescription())) + .andExpect(jsonPath("$.data.roomCode").value(roomCode.getRoomCode())) + .andDo(document("create-room-success", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("capacity").type(JsonFieldType.NUMBER).description("정원"), + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호").optional() + ), + responseFields( + fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드"), + fieldWithPath("status").type(JsonFieldType.STRING).description("상태"), + fieldWithPath("result").type(JsonFieldType.STRING).description("결과"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.roomCode").type(JsonFieldType.STRING).description("방 식별 코드") + ) + )); + } + + @Test + @DisplayName("방 생성 실패: 요청 데이터 오류가 발생했습니다") + void createRoomFail_RoomSettingError() throws Exception { + // given + // Setting up request data for creating a room + RoomSettings roomSettings = RoomSettings.builder() + .capacity(11) + .title(" ") + .password("a1b2") + .build(); + + // when / then + mockMvc.perform(post("/rooms") + .content(objectMapper.writeValueAsString(roomSettings)) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(cookie().doesNotExist(cookieProperties.getName())) + .andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value())) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.getReasonPhrase())) + .andExpect(jsonPath("$.result").value(ResponseResult.EXCEPTION_OCCURRED.getDescription())) + .andExpect(jsonPath("$.data").isArray()) + .andDo(document("create-room-fail-room-setting-error", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("capacity").type(JsonFieldType.NUMBER).description("정원"), + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호").optional() + ), + responseFields( + fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드"), + fieldWithPath("status").type(JsonFieldType.STRING).description("상태"), + fieldWithPath("result").type(JsonFieldType.STRING).description("결과"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("응답 데이터"), + fieldWithPath("data[].type").type(JsonFieldType.STRING).description("오류 타입"), + fieldWithPath("data[].message").type(JsonFieldType.STRING).description("오류 메시지") + ) + )); + } + + @Test + @DisplayName("방 생성 실패: 다수의 방에 참가할 수 없습니다") + void createRoomFail_SingleRoomParticipantViolation() throws Exception { + // given + // Setting up session cookie and request data for creating a room + // This indicates that a session cookie is already present, implying participation in a room + Cookie sessionCookie = new Cookie(cookieProperties.getName(), "a85192c998454a1ea055"); + RoomSettings roomSettings = RoomSettings.builder() + .capacity(10) + .title("재밌는 쇼츠 같이 보기") + .password(null) + .build(); + + // when / then + mockMvc.perform(post("/rooms") + .content(objectMapper.writeValueAsString(roomSettings)) + .contentType(MediaType.APPLICATION_JSON) + .cookie(sessionCookie)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(cookie().doesNotExist(cookieProperties.getName())) + .andExpect(jsonPath("$.code").value(SINGLE_ROOM_PARTICIPATION_VIOLATION.getStatus().value())) + .andExpect(jsonPath("$.status").value(SINGLE_ROOM_PARTICIPATION_VIOLATION.getStatus().getReasonPhrase())) + .andExpect(jsonPath("$.result").value(ResponseResult.EXCEPTION_OCCURRED.getDescription())) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].type").value(SingleRoomParticipationViolationException.class.getSimpleName())) + .andExpect(jsonPath("$.data[0].message").value(SINGLE_ROOM_PARTICIPATION_VIOLATION.getMessage())) + .andDo(document("create-room-fail-single-room-participant-violation", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("capacity").type(JsonFieldType.NUMBER).description("정원"), + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호").optional() + ), + responseFields( + fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드"), + fieldWithPath("status").type(JsonFieldType.STRING).description("상태"), + fieldWithPath("result").type(JsonFieldType.STRING).description("결과"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("응답 데이터"), + fieldWithPath("data[].type").type(JsonFieldType.STRING).description("오류 타입"), + fieldWithPath("data[].message").type(JsonFieldType.STRING).description("오류 메시지") + ) + )); + } + +} diff --git a/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet b/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet new file mode 100644 index 0000000..16960f2 --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet @@ -0,0 +1,14 @@ +==== Request Fields +|=== +|Path|Type|Optional|Description + +{{#fields}} + +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/fields}} + +|=== diff --git a/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet b/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet new file mode 100644 index 0000000..80008b7 --- /dev/null +++ b/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet @@ -0,0 +1,14 @@ +==== Response Fields +|=== +|Path|Type|Optional|Description + +{{#fields}} + +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} + +{{/fields}} + +|===