Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package framey.member.domain.constant;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.regex.Pattern;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class NicknameConstant {
public static final int MAX_NICKNAME_LENGTH = 10;
public static final Pattern EDGE_WHITESPACE = Pattern.compile(
"^[\\p{javaWhitespace}\\p{IsWhite_Space}]+|[\\p{javaWhitespace}\\p{IsWhite_Space}]+$"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
public static final String BASE_NICKNAME = "framey";
public static final int SUFFIX_DIGITS = 4;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package framey.member.domain.constant;

public enum Provider {
GOOGLE,
KAKAO,
APPLE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package framey.member.domain.exception;

import framey.common.response.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum MemberErrorCode implements ErrorCode {
NICKNAME_REQUIRED("MB-001", "닉네임을 입력해 주세요.", HttpStatus.BAD_REQUEST),
MAX_NICKNAME_LENGTH("MB-002", "닉네임은 10자 이내로 입력해 주세요.", HttpStatus.BAD_REQUEST);

private final String value;
private final String message;
private final HttpStatus httpStatus;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package framey.member.domain.model;

import framey.common.exception.BusinessException;
import framey.common.jpa.BaseEntity;
import framey.member.domain.constant.Provider;
import framey.member.domain.exception.MemberErrorCode;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Column;
import jakarta.persistence.Enumerated;
import jakarta.persistence.EnumType;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.UUID;

import static framey.member.domain.constant.NicknameConstant.EDGE_WHITESPACE;
import static framey.member.domain.constant.NicknameConstant.MAX_NICKNAME_LENGTH;

@Entity
@Table(
uniqueConstraints = {
@UniqueConstraint(name = "uk_member_provider", columnNames = {"provider", "provider_id"}),
@UniqueConstraint(name = "uk_member_nickname", columnNames = {"nickname"}),
Comment thread
young0311 marked this conversation as resolved.
Resolved
@UniqueConstraint(name = "uk_member_email", columnNames = {"email"}),
}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;

@Column(updatable = false, nullable = false)
Comment thread
young0311 marked this conversation as resolved.
Resolved
@NotBlank
@Email
private String email;

@Column(length = MAX_NICKNAME_LENGTH, nullable = false)
@NotBlank
private String nickname;

@Column(updatable = false, unique = true, nullable = false)
private UUID entityId;
Comment thread
young0311 marked this conversation as resolved.
Resolved

@Column(length = 2048, nullable = false)
Comment thread
nimuseel marked this conversation as resolved.
Resolved
@NotBlank
private String profileImageUrl;

@Column(updatable = false, nullable = false)
@NotBlank
private String providerId;

@Enumerated(EnumType.STRING)
@Column(updatable = false, nullable = false)
@NotNull
private Provider provider;

private Member(
String email,
String nickname,
String profileImageUrl,
String providerId,
Provider provider
) {
this.email = email;
this.nickname = validateNickname(nickname);
this.profileImageUrl = profileImageUrl;
this.providerId = providerId;
this.provider = provider;
this.entityId = UUID.randomUUID();
}

public static Member signUp(
Comment thread
young0311 marked this conversation as resolved.
Resolved
String email,
String nickname,
String profileImageUrl,
String providerId,
Provider provider
) {
return new Member(email, nickname, profileImageUrl, providerId, provider);
}

private static String validateNickname(String nickname) {
if (nickname == null) {
throw new BusinessException(MemberErrorCode.NICKNAME_REQUIRED);
}

String stripedNickname = EDGE_WHITESPACE.matcher(nickname).replaceAll("");
Comment thread
young0311 marked this conversation as resolved.
Resolved

if (stripedNickname.isEmpty()) {
throw new BusinessException(MemberErrorCode.NICKNAME_REQUIRED);
}


// NOTE: 유니코드 코드 포인트 기준으로 길이 검증
if (stripedNickname.codePointCount(0, stripedNickname.length()) > MAX_NICKNAME_LENGTH) {
Comment thread
young0311 marked this conversation as resolved.
Resolved
throw new BusinessException(MemberErrorCode.MAX_NICKNAME_LENGTH);
}

return stripedNickname;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package framey.member.domain.model;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;

import static framey.member.domain.constant.NicknameConstant.BASE_NICKNAME;
import static framey.member.domain.constant.NicknameConstant.EDGE_WHITESPACE;
import static framey.member.domain.constant.NicknameConstant.MAX_NICKNAME_LENGTH;
import static framey.member.domain.constant.NicknameConstant.SUFFIX_DIGITS;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class NicknameGenerator {
public static String normalize(String nickname) {
if (nickname == null) {
return BASE_NICKNAME;
}

String stripedNickname = EDGE_WHITESPACE.matcher(nickname).replaceAll("");

if (stripedNickname.isEmpty()) {
return BASE_NICKNAME;
}

return truncate(stripedNickname, MAX_NICKNAME_LENGTH);
}

public static String appendSuffix(String base) {
Comment thread
nimuseel marked this conversation as resolved.
Resolved
int baseLength = base.codePointCount(0, base.length());
int suffixLength = Math.max(SUFFIX_DIGITS, MAX_NICKNAME_LENGTH - baseLength);

StringBuilder nickname = new StringBuilder(truncate(base, MAX_NICKNAME_LENGTH - suffixLength));
for (int i = 0; i < suffixLength; i++) {
nickname.append(ThreadLocalRandom.current().nextInt(10));
}
return nickname.toString();
}

private static String truncate(String value, int maxLength) {
if (value.codePointCount(0, value.length()) <= maxLength) {
return value;
}
return value.substring(0, value.offsetByCodePoints(0, maxLength));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package framey.member.domain.member;

import framey.common.exception.BusinessException;
import framey.member.domain.constant.Provider;
import framey.member.domain.model.Member;
import framey.member.domain.exception.MemberErrorCode;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

class MemberTest {
Comment thread
nimuseel marked this conversation as resolved.
Resolved

@ParameterizedTest
@ValueSource(strings = {" ", " ", "\u3000"})
@NullAndEmptySource
@DisplayName("닉네임이 비어 있거나 null이면 가입할 수 없다")
void signUpFailWhenNicknameIsBlank(String nickname) {
Assertions.assertThatThrownBy(() ->
Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE)
)
.isInstanceOf(BusinessException.class)
.extracting("errorCode")
.isEqualTo(MemberErrorCode.NICKNAME_REQUIRED);
}

@ParameterizedTest
@ValueSource(strings = {" Framey ", "\u3000Framey\u3000"})
@DisplayName("닉네임에 공백(유니코드 공백 포함)이 있으면 제거된다")
void signUpStripsNicknameWhitespace(String nickname) {
Member member = Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE);

Assertions.assertThat(member.getEntityId()).isNotNull();
Assertions.assertThat(member.getNickname()).isEqualTo(nickname.strip());
}

@Test
@DisplayName("닉네임이 10자 이내면 가입할 수 있다")
void signUpSuccessWhenNicknameIsWithinMaxLength() {
String nickname = "0123456789";
Member member = Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE);

Assertions.assertThat(nickname.length()).isEqualTo(10);
Assertions.assertThat(member.getNickname()).isEqualTo(nickname);
Assertions.assertThat(member.getEntityId()).isNotNull();
}

@Test
@DisplayName("닉네임의 이모지는 1글자로 센다")
void signUpCountsEmojiAsOneChar() {
String nickname = "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂";
Member member = Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE);

Assertions.assertThat(nickname.codePointCount(0, nickname.length())).isEqualTo(10);
Assertions.assertThat(member.getNickname()).isEqualTo(nickname);
Assertions.assertThat(member.getEntityId()).isNotNull();
}

@ParameterizedTest
@ValueSource(strings = {"01234567890", "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂"})
@DisplayName("닉네임 길이는 10자를 초과할 수 없다")
void signUpFailWhenNicknameOverMaxLength(String nickname) {
Assertions.assertThat(nickname.codePointCount(0, nickname.length())).isEqualTo(11);
Assertions.assertThatThrownBy(() ->
Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE)
)
.isInstanceOf(BusinessException.class)
.extracting("errorCode")
.isEqualTo(MemberErrorCode.MAX_NICKNAME_LENGTH);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package framey.member.domain.member;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

import static framey.member.domain.model.NicknameGenerator.appendSuffix;
import static framey.member.domain.model.NicknameGenerator.normalize;

class NicknameGeneratorTest {
Comment thread
young0311 marked this conversation as resolved.
Resolved
@ParameterizedTest
@ValueSource(strings = {" ", "\u3000", "\u00A0"})
@NullAndEmptySource
@DisplayName("닉네임이 비어있을 경우 기본 닉네임을 사용한다")
void useDefaultNicknameWhenNicknameIsBlank(String nickname) {
String generatedNickname = appendSuffix(normalize(nickname));

Assertions.assertThat(generatedNickname).isNotBlank();
Assertions.assertThat(generatedNickname).matches("^framey\\d{4}$");
}

@ParameterizedTest
@ValueSource(strings = { " 프레이미 ", " 프레이미", "프레이미 ", "\u3000프레이미\u3000"})
@DisplayName("닉네임의 공백을 제거한다.")
void stripsNicknameWhiteSpaces(String nickname) {
String generatedNickname = normalize(nickname);

Assertions.assertThat(generatedNickname).isNotBlank();
Assertions.assertThat(generatedNickname).isEqualTo("프레이미");
}

}
Loading