diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..7b676e4 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,74 @@ +name: Build & Deploy to EC2 + +on: + push: + branches: [ development ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 21 + + - name: Cache Gradle + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: ${{ runner.os }}-gradle- + + - name: Build JAR with dev profile + run: ./gradlew bootJar --no-daemon -Dspring.profiles.active=dev + + - name: Docker Login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build & Push Docker Image + uses: docker/build-push-action@v4 + with: + context: . + file: Dockerfile + push: true + tags: docker.io/${{ secrets.DOCKERHUB_USER }}/smart-meal-table:${{ github.sha }} + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Install SSH + run: sudo apt-get update && sudo apt-get install -y openssh-client + + - name: Setup SSH Key + env: + SSH_KEY: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + echo -e "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config + + - name: Deploy to EC2 + run: | + ssh ubuntu@${{ secrets.EC2_HOST }} << 'EOF' + # 1) 기존 컨테이너 제거 + docker rm -f smart-meal-table || true + # 2) 최신 이미지 pull + docker pull docker.io/${{ secrets.DOCKERHUB_USER }}/smart-meal-table:${{ github.sha }} + # 3) 컨테이너 실행 (dev 프로파일) + docker run -d \ + --name smart-meal-table \ + -v /home/ubuntu/app:/config \ + -e SPRING_PROFILES_ACTIVE=dev \ + -p 8080:8080 \ + docker.io/${{ secrets.DOCKERHUB_USER }}/smart-meal-table:${{ github.sha }} + EOF diff --git a/.gitignore b/.gitignore index 941ebc9..c6d4325 100644 --- a/.gitignore +++ b/.gitignore @@ -315,7 +315,12 @@ gradle-app.setting # Java heap dump *.hprof -# yml +# 모든 yml 파일 무시 *.yml +# → 하지만 GitHub Actions 워크플로우는 예외 처리 +!/.github/ +!/.github/workflows/ +!/.github/workflows/ci-cd.yml + # End of https://www.toptal.com/developers/gitignore/api/java,intellij,macos,gradle,git,maven,visualstudiocode,windows diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c968a73 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM openjdk:21-jdk + +LABEL authors="luna" + +ARG JAR_FILE=build/libs/*.jar + +COPY ${JAR_FILE} app.jar + +ENV SPRING_PROFILES_ACTIVE=dev + +ENTRYPOINT ["java","-jar", "/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4e792a1..5e1b84f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,44 +1,54 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.4.4' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.4.4' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.stcom' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-batch' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-jdbc' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.batch:spring-batch-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 직렬화/역직렬화에 Jackson 사용 + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.batch:spring-batch-test' + testImplementation 'com.h2database:h2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } + +jar { + enabled = false +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/SmartmealtableApplication.java b/src/main/java/com/stcom/smartmealtable/SmartmealtableApplication.java index 7e68ba0..1173f6d 100644 --- a/src/main/java/com/stcom/smartmealtable/SmartmealtableApplication.java +++ b/src/main/java/com/stcom/smartmealtable/SmartmealtableApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class SmartmealtableApplication { - public static void main(String[] args) { - SpringApplication.run(SmartmealtableApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(SmartmealtableApplication.class, args); + } } diff --git a/src/main/java/com/stcom/smartmealtable/config/SwaggerConfig.java b/src/main/java/com/stcom/smartmealtable/config/SwaggerConfig.java new file mode 100644 index 0000000..167b891 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/config/SwaggerConfig.java @@ -0,0 +1,55 @@ +package com.stcom.smartmealtable.config; + +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springdoc.core.utils.SpringDocUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.MethodParameter; + +@Configuration +@Profile({"local", "dev"}) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER +) +@OpenAPIDefinition( + security = @SecurityRequirement(name = "bearerAuth"), + info = @Info( + title = "SmartMealTable API 문서", + version = "v1", + description = "SmartMealTable API 명세서입니다." + ) +) +public class SwaggerConfig { + + static { + SpringDocUtils.getConfig().addAnnotationsToIgnore(UserContext.class) + .addResponseWrapperToIgnore(ApiResponse.class); + } + + @Bean + public OperationCustomizer userContextSecurityCustomizer() { + return (operation, handlerMethod) -> { + for (MethodParameter param : handlerMethod.getMethodParameters()) { + if (param.hasParameterAnnotation(UserContext.class)) { + operation.addSecurityItem( + new io.swagger.v3.oas.models.security.SecurityRequirement().addList("bearerAuth")); + break; + } + } + return operation; + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/domain/Address/Address.java b/src/main/java/com/stcom/smartmealtable/domain/Address/Address.java new file mode 100644 index 0000000..293f7de --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/Address/Address.java @@ -0,0 +1,44 @@ +package com.stcom.smartmealtable.domain.Address; + +import jakarta.persistence.Embeddable; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor +public class Address { + + private String lotNumberAddress; + + private String roadAddress; + + private String detailAddress; + + private Double latitude; + + private Double longitude; + + @Builder + public Address(String lotNumberAddress, String roadAddress, String detailAddress, String alias, Double latitude, + Double longitude, AddressType type) { + this.lotNumberAddress = lotNumberAddress; + this.roadAddress = roadAddress; + this.detailAddress = detailAddress; + this.latitude = latitude; + this.longitude = longitude; + } + + + public void updateAddress(String lotNumberAddress, String roadAddress, String detailAddress, + Double latitude, Double longitude) { + this.lotNumberAddress = lotNumberAddress; + this.roadAddress = roadAddress; + this.detailAddress = detailAddress; + this.latitude = latitude; + this.longitude = longitude; + } + + +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/Address/AddressEntity.java b/src/main/java/com/stcom/smartmealtable/domain/Address/AddressEntity.java new file mode 100644 index 0000000..e7e138a --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/Address/AddressEntity.java @@ -0,0 +1,73 @@ +package com.stcom.smartmealtable.domain.Address; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +@Table(name = "address") +public class AddressEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "address_id") + private Long id; + + @Embedded + private Address address; + + @Column(name = "is_primary") + private boolean primary = false; + + @Enumerated(EnumType.STRING) + private AddressType type; + + private String alias; + + @Builder + public AddressEntity(Address address, AddressType type, String alias) { + this.address = address; + this.type = type; + this.alias = alias; + } + + public AddressEntity(Address address) { + this.address = address; + } + + public void markPrimary() { + this.primary = true; + } + + public void unmarkPrimary() { + this.primary = false; + } + + public boolean isPrimaryAddress() { + return primary; + } + + public void changeAddressType(AddressType newType) { + this.type = newType; + } + + public void changeAlias(String newAlias) { + this.alias = newAlias; + } + + + public void changeAddress(Address newAddress) { + this.address = newAddress; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/Address/AddressType.java b/src/main/java/com/stcom/smartmealtable/domain/Address/AddressType.java new file mode 100644 index 0000000..33f0de4 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/Address/AddressType.java @@ -0,0 +1,5 @@ +package com.stcom.smartmealtable.domain.Address; + +public enum AddressType { + HOME, SCHOOL, OFFICE, ETC +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java new file mode 100644 index 0000000..7dc44de --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/Budget.java @@ -0,0 +1,69 @@ +package com.stcom.smartmealtable.domain.Budget; + +import com.stcom.smartmealtable.domain.common.BaseTimeEntity; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn +@NoArgsConstructor +public abstract class Budget extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "budget_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_profile_id") + private MemberProfile memberProfile; + + private BigDecimal spendAmount = BigDecimal.ZERO; + + @Column(name = "budget_limit") + private BigDecimal limit; + + protected Budget(MemberProfile memberProfile, BigDecimal limit) { + this.memberProfile = memberProfile; + this.limit = limit; + } + + public void addSpent(BigDecimal amount) { + this.spendAmount = spendAmount.add(amount); + } + + public void addSpent(int amount) { + this.spendAmount = spendAmount.add(BigDecimal.valueOf(amount)); + } + + public void addSpent(double amount) { + this.spendAmount = spendAmount.add(BigDecimal.valueOf(amount)); + } + + public void resetSpent() { + this.spendAmount = BigDecimal.ZERO; + } + + public BigDecimal getAvailableAmount() { + return limit.subtract(spendAmount); + } + + public boolean isOverLimit() { + return spendAmount.compareTo(limit) > 0; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/DailyBudget.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/DailyBudget.java new file mode 100644 index 0000000..6477ae1 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/DailyBudget.java @@ -0,0 +1,24 @@ +package com.stcom.smartmealtable.domain.Budget; + +import com.stcom.smartmealtable.domain.member.MemberProfile; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class DailyBudget extends Budget { + + public DailyBudget(MemberProfile memberProfile, BigDecimal limit, + LocalDate date) { + super(memberProfile, limit); + this.date = date; + } + + @Column(name = "daily_budget_date") + private LocalDate date; +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudget.java b/src/main/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudget.java new file mode 100644 index 0000000..beb87b7 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudget.java @@ -0,0 +1,27 @@ +package com.stcom.smartmealtable.domain.Budget; + +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.infrastructure.persistence.YearMonthConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import java.math.BigDecimal; +import java.time.YearMonth; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class MonthlyBudget extends Budget { + + public MonthlyBudget(MemberProfile memberProfile, BigDecimal limit, + YearMonth yearMonth) { + super(memberProfile, limit); + this.yearMonth = yearMonth; + } + + @Convert(converter = YearMonthConverter.class) + @Column(name = "budget_year_month") + private YearMonth yearMonth; +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/common/BaseEntity.java b/src/main/java/com/stcom/smartmealtable/domain/common/BaseEntity.java new file mode 100644 index 0000000..9e51b38 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/common/BaseEntity.java @@ -0,0 +1,20 @@ +package com.stcom.smartmealtable.domain.common; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity extends BaseTimeEntity { + + @CreatedBy + private String createdBy; + + @LastModifiedBy + private String lastModifiedBy; +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/common/BaseTimeEntity.java b/src/main/java/com/stcom/smartmealtable/domain/common/BaseTimeEntity.java new file mode 100644 index 0000000..fc35204 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/common/BaseTimeEntity.java @@ -0,0 +1,22 @@ +package com.stcom.smartmealtable.domain.common; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime lastModifiedDate; + +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/domain/food/FoodCategory.java b/src/main/java/com/stcom/smartmealtable/domain/food/FoodCategory.java new file mode 100644 index 0000000..d3075fd --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/food/FoodCategory.java @@ -0,0 +1,23 @@ +package com.stcom.smartmealtable.domain.food; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class FoodCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "food_category_id") + private Long id; + + @Column(unique = true, nullable = false) + private String name; +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/food/MemberCategoryPreference.java b/src/main/java/com/stcom/smartmealtable/domain/food/MemberCategoryPreference.java new file mode 100644 index 0000000..0b4458d --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/food/MemberCategoryPreference.java @@ -0,0 +1,57 @@ +package com.stcom.smartmealtable.domain.food; + +import com.stcom.smartmealtable.domain.member.MemberProfile; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table +@NoArgsConstructor +@Getter +public class MemberCategoryPreference { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_category_preference_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_profile_id") + private MemberProfile memberProfile; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "food_category_id") + private FoodCategory category; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PreferenceType type; // LIKE or DISLIKE + + @Column(nullable = false) + private Integer priority; + + private Double weight; + + @Builder + public MemberCategoryPreference(MemberProfile memberProfile, + FoodCategory category, + PreferenceType type, + Integer priority) { + this.memberProfile = memberProfile; + this.category = category; + this.type = type; + this.priority = priority; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/food/PreferenceType.java b/src/main/java/com/stcom/smartmealtable/domain/food/PreferenceType.java new file mode 100644 index 0000000..0ba42bf --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/food/PreferenceType.java @@ -0,0 +1,5 @@ +package com.stcom.smartmealtable.domain.food; + +public enum PreferenceType { + LIKE, DISLIKE +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/group/CompanyGroup.java b/src/main/java/com/stcom/smartmealtable/domain/group/CompanyGroup.java new file mode 100644 index 0000000..8aee3e1 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/group/CompanyGroup.java @@ -0,0 +1,21 @@ +package com.stcom.smartmealtable.domain.group; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class CompanyGroup extends Group { + + @Enumerated(EnumType.STRING) + private IndustryType industryType; + + @Override + public String getTypeName() { + return industryType.getDescription(); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/group/Group.java b/src/main/java/com/stcom/smartmealtable/domain/group/Group.java new file mode 100644 index 0000000..d221901 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/group/Group.java @@ -0,0 +1,37 @@ +package com.stcom.smartmealtable.domain.group; + +import com.stcom.smartmealtable.domain.Address.Address; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "affiliation") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn +@Getter +@NoArgsConstructor +public abstract class Group { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "affiliation_id") + private Long id; + + @Embedded + private Address address; + + private String name; + + public abstract String getTypeName(); + +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/group/IndustryType.java b/src/main/java/com/stcom/smartmealtable/domain/group/IndustryType.java new file mode 100644 index 0000000..f8e39ac --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/group/IndustryType.java @@ -0,0 +1,15 @@ +package com.stcom.smartmealtable.domain.group; + +public enum IndustryType { + IT("IT"), FINANCE("파이낸스"), MANUFACTURING("제조업"), SERVICE("서비스"); + + private final String description; + + IndustryType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/group/SchoolGroup.java b/src/main/java/com/stcom/smartmealtable/domain/group/SchoolGroup.java new file mode 100644 index 0000000..17b1858 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/group/SchoolGroup.java @@ -0,0 +1,21 @@ +package com.stcom.smartmealtable.domain.group; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class SchoolGroup extends Group { + + @Enumerated(EnumType.STRING) + private SchoolType schoolType; + + @Override + public String getTypeName() { + return schoolType.name(); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/group/SchoolType.java b/src/main/java/com/stcom/smartmealtable/domain/group/SchoolType.java new file mode 100644 index 0000000..965fef4 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/group/SchoolType.java @@ -0,0 +1,16 @@ +package com.stcom.smartmealtable.domain.group; + +public enum SchoolType { + UNIVERSITY_FOUR_YEAR("대학교(4년제)"), UNIVERSITY_TWO_YEAR("대학교(2년제)"), + HIGH_SCHOOL("고등학교"), MIDDLE_SCHOOL("중학교"); + + private final String description; + + SchoolType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/member/Member.java b/src/main/java/com/stcom/smartmealtable/domain/member/Member.java new file mode 100644 index 0000000..ef3cb8b --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/member/Member.java @@ -0,0 +1,83 @@ +package com.stcom.smartmealtable.domain.member; + +import com.stcom.smartmealtable.domain.common.BaseTimeEntity; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import jakarta.validation.constraints.Email; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Email + private String email; + + @Embedded + private MemberPassword password; + + private String fullName; + + + // TODO: 이메일 인증 기능 구현해야함 + private boolean isEmailVerified = true; + + @OneToOne(mappedBy = "member") + private MemberProfile memberProfile; + + public Member(String email) { + this.email = email; + } + + @Builder + public Member(String fullName, String email, String rawPassword) throws PasswordPolicyException { + this.fullName = fullName; + this.email = email; + this.password = new MemberPassword(rawPassword); + } + + protected void linkMemberProfile(MemberProfile profile) { + this.memberProfile = profile; + } + + public void changePassword(String rawOldPassword, String rawNewPassword) + throws PasswordFailedExceededException, PasswordPolicyException { + if (rawOldPassword.isBlank() || rawNewPassword.isBlank()) { + throw new IllegalArgumentException("빈 비밀번호를 입력했습니다"); + } + password.changePassword(rawNewPassword, rawOldPassword); + } + + public boolean isMatchedPassword(final String rawPassword) throws PasswordFailedExceededException { + return password.isMatched(rawPassword); + } + + public boolean isEmailVerified() { + return isEmailVerified; + } + + public void verifyEmail() { + this.isEmailVerified = true; + } + + public boolean isProfileRegistered() { + return memberProfile == null; + } + + +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/member/MemberPassword.java b/src/main/java/com/stcom/smartmealtable/domain/member/MemberPassword.java new file mode 100644 index 0000000..9e35bb9 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/member/MemberPassword.java @@ -0,0 +1,119 @@ +package com.stcom.smartmealtable.domain.member; + + +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import jakarta.persistence.Embeddable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor +public class MemberPassword { + + private final static int MAX_FAILED_COUNT = 5; + private final static long TTL = 1209_604; // 2 weeks + + private String password_hash; + + private int failedCount; + + private LocalDateTime expirationDate; + + private long ttl; + + public MemberPassword(String rawPassword) throws PasswordPolicyException { + checkPasswordPolicy(rawPassword); + this.password_hash = encodePassword(rawPassword); + this.expirationDate = LocalDateTime.now().plusSeconds(TTL); + this.ttl = TTL; // 2 weeks + this.failedCount = 0; + } + + public void checkPasswordPolicy(String rawPassword) throws PasswordPolicyException { + if (rawPassword.contains(" ")) { + throw new PasswordPolicyException("비밀번호는 공백을 포함할 수 없습니다"); + } + + if (rawPassword.length() < 8) { + throw new PasswordPolicyException("비밀번호는 8자 이상이어야 합니다."); + } + + if (rawPassword.length() > 20) { + throw new PasswordPolicyException("비밀번호는 최대 20자까지 가능합니다."); + } + + if (!rawPassword.matches("^[\\x21-\\x7E]+$")) { + throw new PasswordPolicyException("비밀번호는 영문자, 숫자, 특수문자로만 구성되어야 합니다."); + } + } + + public boolean isMatched(final String rawPassword) throws PasswordFailedExceededException { + checkFailedCount(); + final boolean matches = isMatches(rawPassword); + updateFailedCount(matches); + return matches; + } + + public boolean isPasswordExpired() { + return LocalDateTime.now().isAfter(expirationDate); + } + + private void checkFailedCount() throws PasswordFailedExceededException { + if (failedCount >= MAX_FAILED_COUNT) { + throw new PasswordFailedExceededException(); + } + } + + private boolean isMatches(String rawPassword) { + return password_hash.equals(encodePassword(rawPassword)); + } + + private void updateFailedCount(boolean matches) { + if (matches) { + failedCount = 0; + return; + } + + failedCount++; + } + + public void changePassword(final String newPassword, final String oldPassword) + throws PasswordFailedExceededException, PasswordPolicyException { + if (!isMatches(oldPassword)) { + throw new PasswordFailedExceededException("기존 비밀번호가 일치하지 않습니다"); + } + if (newPassword.equals(oldPassword)) { + throw new PasswordPolicyException("기존 비밀번호와 새 비밀번호가 동일합니다. 기존 비밀번호와 동일하지 않은 비밀번호로 설정해주세요"); + } + checkPasswordPolicy(newPassword); + password_hash = encodePassword(newPassword); + extendExpirationDate(); + } + + private void extendExpirationDate() { + expirationDate = LocalDateTime.now().plusSeconds(ttl); + } + + private String encodePassword(String newPassword) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(newPassword.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)) + .append(Character.forDigit((b & 0xF), 16)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java b/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java new file mode 100644 index 0000000..48e7af4 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/member/MemberProfile.java @@ -0,0 +1,141 @@ +package com.stcom.smartmealtable.domain.member; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressEntity; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.common.BaseTimeEntity; +import com.stcom.smartmealtable.domain.group.Group; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class MemberProfile extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_profile_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL) + @JoinColumn(name = "member_id") + private Member member; + + private String nickName; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "member_profile_id") + private List addressHistory = new ArrayList<>(); + + @Enumerated(EnumType.STRING) + private MemberType type; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "affiliation_id") + private Group group; + + @Column(name = "default_monthly_limit") + private BigDecimal defaultMonthlyLimit; + + @Column(name = "default_daily_limit") + private BigDecimal defaultDailyLimit; + + + @Builder + public MemberProfile(Member member, String nickName, List addressHistory, MemberType type, + Group group) { + linkMember(member); + this.nickName = nickName; + this.addressHistory = addressHistory; + this.type = type; + this.group = group; + } + + public void addAddress(AddressEntity addressEntity) { + addressHistory.add(addressEntity); + if (addressHistory.size() == 1) { + setPrimaryAddress(addressEntity); + } + } + + public void removeAddress(AddressEntity addressEntity) { + boolean wasPrimary = addressEntity.isPrimaryAddress(); + addressHistory.stream() + .filter(a -> a.equals(addressEntity)) + .findFirst().ifPresentOrElse( + a -> addressHistory.remove(a), + () -> { + throw new IllegalStateException("Primary Address가 없습니다"); + } + ); + if (addressHistory.size() > 1 && wasPrimary) { + setPrimaryAddress(addressHistory.getFirst()); + } + } + + public AddressEntity findPrimaryAddress() { + return addressHistory.stream() + .filter(AddressEntity::isPrimaryAddress) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Primary Address가 없습니다")); + } + + public void setPrimaryAddress(AddressEntity target) { + addressHistory.forEach(AddressEntity::unmarkPrimary); + target.markPrimary(); + } + + public void changeAddress(AddressEntity target, Address address, String alias, + AddressType addressType) { + AddressEntity entity = addressHistory.stream() + .filter(addressEntity -> addressEntity.equals(target)) + .findFirst().orElseThrow(() -> new IllegalStateException("해당 회원의 주소 엔티티가 아닙니다")); + entity.changeAddressType(addressType); + entity.changeAlias(alias); + entity.changeAddress(address); + } + + public void linkMember(Member member) { + member.linkMemberProfile(this); + this.member = member; + } + + public void changeNickName(String newNickName) { + if (newNickName.isBlank()) { + throw new IllegalArgumentException("이름은 비어 있을 수 없습니다."); + } + this.nickName = newNickName; + } + + public void changeMemberType(MemberType memberType) { + this.type = memberType; + } + + + public void changeGroup(Group newGroup) { + this.group = newGroup; + } + + public void registerDefaultBudgets(BigDecimal defaultDailyLimit, BigDecimal defaultMonthlyLimit) { + this.defaultDailyLimit = defaultDailyLimit; + this.defaultMonthlyLimit = defaultMonthlyLimit; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/member/MemberType.java b/src/main/java/com/stcom/smartmealtable/domain/member/MemberType.java new file mode 100644 index 0000000..f4d40f5 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/member/MemberType.java @@ -0,0 +1,5 @@ +package com.stcom.smartmealtable.domain.member; + +public enum MemberType { + STUDENT, WORKER, OTHER +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/social/SocialAccount.java b/src/main/java/com/stcom/smartmealtable/domain/social/SocialAccount.java new file mode 100644 index 0000000..8c5cc14 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/social/SocialAccount.java @@ -0,0 +1,65 @@ +package com.stcom.smartmealtable.domain.social; + +import com.stcom.smartmealtable.domain.common.BaseTimeEntity; +import com.stcom.smartmealtable.domain.member.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class SocialAccount extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "social_account_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + private String provider; + + private String providerUserId; + + private String tokenType; + + private String accessToken; + + private String refreshToken; + + private LocalDateTime tokenExpiresAt; + + @Builder + public SocialAccount(Member member, String provider, String providerUserId, String tokenType, + String accessToken, String refreshToken, LocalDateTime tokenExpiresAt) { + this.member = member; + this.provider = provider; + this.providerUserId = providerUserId; + this.tokenType = tokenType; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.tokenExpiresAt = tokenExpiresAt; + } + + public void updateToken(String accessToken, String refreshToken, LocalDateTime tokenExpiresAt) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.tokenExpiresAt = tokenExpiresAt; + } + + public boolean isProfileRegistered() { + return member.isProfileRegistered(); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/term/Term.java b/src/main/java/com/stcom/smartmealtable/domain/term/Term.java new file mode 100644 index 0000000..14c809c --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/term/Term.java @@ -0,0 +1,32 @@ +package com.stcom.smartmealtable.domain.term; + +import com.stcom.smartmealtable.domain.common.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class Term extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "term_id") + private Long id; + + private Integer version; + + private String title; + + @Lob + private String content; + + private Boolean isRequired; + +} diff --git a/src/main/java/com/stcom/smartmealtable/domain/term/TermAgreement.java b/src/main/java/com/stcom/smartmealtable/domain/term/TermAgreement.java new file mode 100644 index 0000000..a932ee7 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/domain/term/TermAgreement.java @@ -0,0 +1,41 @@ +package com.stcom.smartmealtable.domain.term; + +import com.stcom.smartmealtable.domain.common.BaseTimeEntity; +import com.stcom.smartmealtable.domain.member.Member; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class TermAgreement extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "term_id") + private Term term; + + private Boolean isAgreed; + + @Builder + public TermAgreement(Member member, Term term, Boolean isAgreed) { + this.member = member; + this.term = term; + this.isAgreed = isAgreed; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/exception/ExternApiStatusError.java b/src/main/java/com/stcom/smartmealtable/exception/ExternApiStatusError.java new file mode 100644 index 0000000..a3b9b78 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/exception/ExternApiStatusError.java @@ -0,0 +1,7 @@ +package com.stcom.smartmealtable.exception; + +public class ExternApiStatusError extends RuntimeException { + public ExternApiStatusError(String message) { + super(message); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/exception/PasswordFailedExceededException.java b/src/main/java/com/stcom/smartmealtable/exception/PasswordFailedExceededException.java new file mode 100644 index 0000000..cb2397b --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/exception/PasswordFailedExceededException.java @@ -0,0 +1,24 @@ +package com.stcom.smartmealtable.exception; + +public class PasswordFailedExceededException extends Exception { + public PasswordFailedExceededException() { + super("비밀번호 실패 횟수가 5회를 초과하였습니다."); + } + + public PasswordFailedExceededException(String message) { + super(message); + } + + public PasswordFailedExceededException(String message, Throwable cause) { + super(message, cause); + } + + public PasswordFailedExceededException(Throwable cause) { + super(cause); + } + + protected PasswordFailedExceededException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/exception/PasswordPolicyException.java b/src/main/java/com/stcom/smartmealtable/exception/PasswordPolicyException.java new file mode 100644 index 0000000..7f981c3 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/exception/PasswordPolicyException.java @@ -0,0 +1,25 @@ +package com.stcom.smartmealtable.exception; + +public class PasswordPolicyException extends Exception { + + public PasswordPolicyException() { + super(); + } + + public PasswordPolicyException(String message) { + super(message); + } + + public PasswordPolicyException(String message, Throwable cause) { + super(message, cause); + } + + public PasswordPolicyException(Throwable cause) { + super(cause); + } + + protected PasswordPolicyException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/AddressApiService.java b/src/main/java/com/stcom/smartmealtable/infrastructure/AddressApiService.java new file mode 100644 index 0000000..cbbfa16 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/AddressApiService.java @@ -0,0 +1,9 @@ +package com.stcom.smartmealtable.infrastructure; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; + +public interface AddressApiService { + + Address createAddressFromRequest(AddressRequest requestDto); +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiService.java b/src/main/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiService.java new file mode 100644 index 0000000..f24d7b0 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiService.java @@ -0,0 +1,151 @@ +package com.stcom.smartmealtable.infrastructure; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +@Service +public class KakaoAddressApiService implements AddressApiService { + + @Value("${kakao.oauth.client-id}") + private String clientId; + + private final RestClient client = RestClient.create(); + + public Address createAddressFromRequest(AddressRequest requestDto) { + AddressSearchResponse addressSearchResponse = client.get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .host("dapi.kakao.com") + .path("/v2/local/search/address") + .queryParam("query", requestDto.getRoadAddress()) + .build()) + .header("Authorization", "KakaoAK " + clientId) + .retrieve() + .body(AddressSearchResponse.class); + + if (addressSearchResponse.getMeta().getTotalCount() >= 2) { + throw new IllegalArgumentException("주소가 모호합니다. 정확한 주소를 입력하세요"); + } + + return Address.builder() + .longitude(Double.parseDouble(addressSearchResponse.getDocuments().getFirst().getLongitude())) + .latitude(Double.parseDouble(addressSearchResponse.getDocuments().getFirst().getLatitude())) + .lotNumberAddress(addressSearchResponse.getDocuments().getFirst().getAddress().getAddressName()) + .roadAddress(addressSearchResponse.getDocuments().getFirst().getRoadAddress().getAddressName()) + .detailAddress(requestDto.getDetailAddress()) + .build(); + } + + @Data + @AllArgsConstructor + static class AddressSearchResponse { + private Meta meta; + private List documents; + } + + @Data + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + static class Meta { + private Integer totalCount; + private Integer pageableCount; + private Boolean isEnd; + } + + @Data + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + static class Document { + private String addressName; + + private String addressType; + + @JsonProperty("x") + private String longitude; + + @JsonProperty("y") + private String latitude; + + private LotAddress address; + + private RoadAddress roadAddress; + + } + + @Data + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + static class LotAddress { + private String addressName; + + @JsonProperty("region_1depth_name") + private String region1depthName; + + @JsonProperty("region_2depth_name") + private String region2depthName; + + @JsonProperty("region_3depth_name") + private String region3depthName; + + @JsonProperty("region_3depth_h_name") + private String region3depthHName; + + private String hCode; + + private String bCode; + + private String mountainYn; + + private String mainAddressNo; + + private String subAddressNo; + + private String x; + private String y; + } + + @Data + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + static class RoadAddress { + private String addressName; + + @JsonProperty("region_1depth_name") + private String region1depthName; + + @JsonProperty("region_2depth_name") + private String region2depthName; + + @JsonProperty("region_3depth_name") + private String region3depthName; + + private String roadName; + + @JsonProperty("underground_yn") + private String undergroundYn; + + @JsonProperty("main_building_no") + private String mainBuildingNo; + + @JsonProperty("sub_building_no") + private String subBuildingNo; + + @JsonProperty("building_name") + private String buildingName; + + @JsonProperty("zone_no") + private String zoneNo; + + private String x; + private String y; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/SocialAuthService.java b/src/main/java/com/stcom/smartmealtable/infrastructure/SocialAuthService.java new file mode 100644 index 0000000..3bd8937 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/SocialAuthService.java @@ -0,0 +1,40 @@ +package com.stcom.smartmealtable.infrastructure; + +import static com.stcom.smartmealtable.infrastructure.social.SocialConst.GOOGLE; +import static com.stcom.smartmealtable.infrastructure.social.SocialConst.KAKAO; + +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.infrastructure.social.GoogleHttpMessage; +import com.stcom.smartmealtable.infrastructure.social.KakaoHttpMessage; +import com.stcom.smartmealtable.infrastructure.social.SocialHttpMessage; +import jakarta.annotation.PostConstruct; +import jakarta.validation.constraints.NotEmpty; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.ResponseSpec; + +@Service +@RequiredArgsConstructor +public class SocialAuthService { + + private final Map socialMap = new HashMap<>(); + private final KakaoHttpMessage kakaoHttpMessage; + private final GoogleHttpMessage googleHttpMessage; + private final RestClient client = RestClient.create(); + + public TokenDto getTokenResponse(@NotEmpty String provider, @NotEmpty String code) { + ResponseSpec responseSpec = socialMap.get(provider).getRequestMessage(client, code).retrieve(); + return socialMap.get(provider).getTokenResponse(responseSpec); + } + + @PostConstruct + public void init() { + socialMap.put(KAKAO, kakaoHttpMessage); + socialMap.put(GOOGLE, googleHttpMessage); + } + + +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/config/RedisConfig.java b/src/main/java/com/stcom/smartmealtable/infrastructure/config/RedisConfig.java new file mode 100644 index 0000000..42e30cc --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/config/RedisConfig.java @@ -0,0 +1,33 @@ +package com.stcom.smartmealtable.infrastructure.config; + +import org.springframework.beans.factory.annotation.Value; +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.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host:localhost}") + private String host; + + @Value("${spring.data.redis.port:6379}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/dto/AddressRequest.java b/src/main/java/com/stcom/smartmealtable/infrastructure/dto/AddressRequest.java new file mode 100644 index 0000000..16f2761 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/dto/AddressRequest.java @@ -0,0 +1,15 @@ +package com.stcom.smartmealtable.infrastructure.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class AddressRequest { + + private String roadAddress; + + private String alias; + + private String detailAddress; +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/dto/JwtTokenResponseDto.java b/src/main/java/com/stcom/smartmealtable/infrastructure/dto/JwtTokenResponseDto.java new file mode 100644 index 0000000..bc674ca --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/dto/JwtTokenResponseDto.java @@ -0,0 +1,22 @@ +package com.stcom.smartmealtable.infrastructure.dto; + +import lombok.Data; +import lombok.ToString; + +@Data +@ToString +public class JwtTokenResponseDto { + + private String accessToken; + private String refreshToken; + private int expiresIn; + private String tokenType; + private boolean isNewUser; + + public JwtTokenResponseDto(String accessToken, String refreshToken, int expiresIn, String tokenType) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiresIn = expiresIn; + this.tokenType = tokenType; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/dto/TokenDto.java b/src/main/java/com/stcom/smartmealtable/infrastructure/dto/TokenDto.java new file mode 100644 index 0000000..7793edf --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/dto/TokenDto.java @@ -0,0 +1,30 @@ +package com.stcom.smartmealtable.infrastructure.dto; + +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class TokenDto { + + private String accessToken; + private String refreshToken; + private Integer expiresIn; + private String tokenType; + private String provider; + private String providerUserId; + private String email; + + @Builder + public TokenDto(String accessToken, String refreshToken, Integer expiresIn, String tokenType, String provider, + String providerUserId, String email) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiresIn = expiresIn; + this.tokenType = tokenType; + this.provider = provider; + this.providerUserId = providerUserId; + this.email = email; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/persistence/YearMonthConverter.java b/src/main/java/com/stcom/smartmealtable/infrastructure/persistence/YearMonthConverter.java new file mode 100644 index 0000000..077d9dd --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/persistence/YearMonthConverter.java @@ -0,0 +1,20 @@ +package com.stcom.smartmealtable.infrastructure.persistence; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.sql.Date; +import java.time.YearMonth; + +@Converter(autoApply = true) +public class YearMonthConverter implements AttributeConverter { + + @Override + public Date convertToDatabaseColumn(YearMonth attribute) { + return (attribute == null ? null : Date.valueOf(attribute.atDay(1))); + } + + @Override + public YearMonth convertToEntityAttribute(Date dbData) { + return (dbData == null ? null : YearMonth.from(dbData.toLocalDate())); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/social/GoogleHttpMessage.java b/src/main/java/com/stcom/smartmealtable/infrastructure/social/GoogleHttpMessage.java new file mode 100644 index 0000000..a683a4d --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/social/GoogleHttpMessage.java @@ -0,0 +1,126 @@ +package com.stcom.smartmealtable.infrastructure.social; + +import static com.stcom.smartmealtable.infrastructure.social.SocialConst.GOOGLE; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.RequestBodySpec; +import org.springframework.web.client.RestClient.ResponseSpec; + +@Component +@Slf4j +public class GoogleHttpMessage implements SocialHttpMessage { + + @Value("${google.oauth.client-id}") + private String clientId; + + @Value("${google.oauth.client-secret}") + private String clientSecret; + + @Value("${google.oauth.redirect-uri}") + private String redirectUri; + + + @Override + public RequestBodySpec getRequestMessage(RestClient client, String code) { + return client.post() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .host("oauth2.googleapis.com") + .path("/token") + .build()) + // form data 로 보내려면 반드시 URL_ENCODED + .headers(h -> h.setContentType(MediaType.APPLICATION_FORM_URLENCODED)) + .body(createFormData(code)); + } + + private MultiValueMap createFormData(String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("grant_type", "authorization_code"); + formData.add("code", code); + formData.add("redirect_uri", redirectUri); + formData.add("client_id", clientId); + formData.add("client_secret", clientSecret); + log.info("client Id = {}", clientId); + return formData; + } + + @Override + public TokenDto getTokenResponse(ResponseSpec responseSpec) { + GoogleTokenResponse tokenResponse = responseSpec.body(GoogleTokenResponse.class); + return TokenDto.builder() + .accessToken(tokenResponse.getAccessToken()) + .refreshToken(tokenResponse.getRefreshToken()) + .expiresIn(tokenResponse.getExpiresIn()) + .tokenType(tokenResponse.getTokenType()) + .provider(GOOGLE) + .providerUserId(extractProviderUserId(tokenResponse.getIdToken())) + .email(extractEmail(tokenResponse.getIdToken())) + .build(); + } + + @Override + public String extractProviderUserId(String idToken) { + if (idToken == null || idToken.isBlank()) { + return null; + } + try { + String[] jwtParts = idToken.split("\\."); + if (jwtParts.length != 3) { + return null; + } + + String payload = new String(Base64.getUrlDecoder().decode(jwtParts[1]), StandardCharsets.UTF_8); + JsonNode payloadJson = new ObjectMapper().readTree(payload); + return payloadJson.path("sub").asText(null); + + } catch (Exception e) { + // 로깅: 어떤 공급자(token issuer)에 대한 토큰인지 같이 찍어도 좋습니다. + log.error("ID 토큰 파싱 오류: {}", e.getMessage()); + return null; + } + } + + public String extractEmail(String idToken) { + if (idToken == null || idToken.isBlank()) { + return null; + } + try { + String[] parts = idToken.split("\\."); + if (parts.length != 3) { + return null; + } + String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); + JsonNode node = new ObjectMapper().readTree(payload); + return node.has("email") ? node.get("email").asText() : null; + } catch (Exception e) { + log.error("Google ID 토큰에서 email 파싱 실패: {}", e.getMessage()); + return null; + } + } + + @Data + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + static class GoogleTokenResponse { + + private String accessToken; + private Integer expiresIn; + private String refreshToken; + private String scope; + private String tokenType; + private String idToken; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/social/KakaoHttpMessage.java b/src/main/java/com/stcom/smartmealtable/infrastructure/social/KakaoHttpMessage.java new file mode 100644 index 0000000..26135af --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/social/KakaoHttpMessage.java @@ -0,0 +1,134 @@ +package com.stcom.smartmealtable.infrastructure.social; + +import static com.stcom.smartmealtable.infrastructure.social.SocialConst.KAKAO; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.RequestBodySpec; +import org.springframework.web.client.RestClient.ResponseSpec; + +@Component +@Slf4j +public class KakaoHttpMessage implements SocialHttpMessage { + + @Value("${kakao.oauth.client-id}") + private String clientId; + + @Value("${kakao.oauth.redirect-uri}") + private String redirectUri; + + @Override + public RequestBodySpec getRequestMessage(RestClient client, String code) { + return client.post() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .host("kauth.kakao.com") + .path("/oauth/token") + .build()) + // form data 로 보내려면 반드시 URL_ENCODED + .headers(h -> h.setContentType(MediaType.APPLICATION_FORM_URLENCODED)) + .body(createFormData(code)); + } + + private MultiValueMap createFormData(String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("grant_type", "authorization_code"); + formData.add("client_id", clientId); + formData.add("redirect_uri", redirectUri); + formData.add("code", code); + log.info("client Id = {}", clientId); + return formData; + } + + + @Override + public TokenDto getTokenResponse(ResponseSpec responseSpec) { + KakaoTokenResponse tokenResponse = responseSpec.body(KakaoTokenResponse.class); + return TokenDto.builder() + .accessToken(tokenResponse.getAccessToken()) + .refreshToken(tokenResponse.getRefreshToken()) + .expiresIn(tokenResponse.getExpiresIn()) + .tokenType(tokenResponse.getTokenType()) + .provider(KAKAO) + .providerUserId(extractProviderUserId(tokenResponse.getIdToken())) + .email(extractEmail(tokenResponse.getIdToken())) + .build(); + } + + @Override + public String extractProviderUserId(String idToken) { + if (idToken == null || idToken.isEmpty()) { + return null; + } + + try { + String[] jwtParts = idToken.split("\\."); + if (jwtParts.length != 3) { + return null; + } + + String payload = new String(java.util.Base64.getUrlDecoder().decode(jwtParts[1])); + + // Jackson ObjectMapper를 사용하여 JSON 파싱 + ObjectMapper mapper = new ObjectMapper(); + JsonNode payloadJson = mapper.readTree(payload); + + return payloadJson.has("sub") ? payloadJson.get("sub").asText() : null; + } catch (Exception e) { + // 예외 발생 시 로깅 및 null 반환 + System.err.println("카카오 ID 토큰 파싱 오류: " + e.getMessage()); + return null; + } + } + + public String extractEmail(String idToken) { + if (idToken == null || idToken.isEmpty()) { + return null; + } + try { + String[] parts = idToken.split("\\."); + if (parts.length != 3) { + return null; + } + String payloadJson = new String( + java.util.Base64.getUrlDecoder().decode(parts[1]), + java.nio.charset.StandardCharsets.UTF_8 + ); + + JsonNode node = new ObjectMapper().readTree(payloadJson); + return node.has("email") ? node.get("email").asText() : null; + } catch (Exception e) { + log.error("카카오 ID 토큰에서 email 파싱 실패", e); + return null; + } + } + + @Data + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + static class KakaoTokenResponse { + + private String tokenType; + + private String accessToken; + + private String idToken; + + private Integer expiresIn; + + private String refreshToken; + + private Integer refreshTokenExpiresIn; + + } +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/social/SocialConst.java b/src/main/java/com/stcom/smartmealtable/infrastructure/social/SocialConst.java new file mode 100644 index 0000000..41dd1db --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/social/SocialConst.java @@ -0,0 +1,8 @@ +package com.stcom.smartmealtable.infrastructure.social; + +public abstract class SocialConst { + + public static final String KAKAO = "kakao"; + + public static final String GOOGLE = "google"; +} diff --git a/src/main/java/com/stcom/smartmealtable/infrastructure/social/SocialHttpMessage.java b/src/main/java/com/stcom/smartmealtable/infrastructure/social/SocialHttpMessage.java new file mode 100644 index 0000000..fec7a22 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/infrastructure/social/SocialHttpMessage.java @@ -0,0 +1,15 @@ +package com.stcom.smartmealtable.infrastructure.social; + +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.RequestBodySpec; +import org.springframework.web.client.RestClient.ResponseSpec; + +public interface SocialHttpMessage { + + RequestBodySpec getRequestMessage(RestClient client, String code); + + TokenDto getTokenResponse(ResponseSpec responseSpec); + + String extractProviderUserId(String idToken); +} diff --git a/src/main/java/com/stcom/smartmealtable/repository/AddressEntityRepository.java b/src/main/java/com/stcom/smartmealtable/repository/AddressEntityRepository.java new file mode 100644 index 0000000..194dd70 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/AddressEntityRepository.java @@ -0,0 +1,8 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.Address.AddressEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AddressEntityRepository extends JpaRepository { + +} diff --git a/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java new file mode 100644 index 0000000..b9bc10b --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/BudgetRepository.java @@ -0,0 +1,25 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.Budget.Budget; +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface BudgetRepository extends JpaRepository { + + @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :memberProfileId") + List findDailyBudgetsViaType(@Param("memberProfileId") Long memberProfileId); + + @Query("select b from Budget b where type(b) = DailyBudget and b.memberProfile.id = :memberProfileId order by treat(b as DailyBudget).date desc") + Optional findFirstDailyBudgetByMemberProfileId(@Param("memberProfileId") Long memberProfileId); + + @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :memberProfileId") + List findMonthlyBudgetsViaType(@Param("memberProfileId") Long memberProfileId); + + @Query("select b from Budget b where type(b) = MonthlyBudget and b.memberProfile.id = :memberProfileId order by treat(b as MonthlyBudget).yearMonth desc") + Optional findFirstMonthlyBudgetByMemberProfileId(@Param("memberProfileId") Long memberProfileId); +} diff --git a/src/main/java/com/stcom/smartmealtable/repository/FoodCategoryRepository.java b/src/main/java/com/stcom/smartmealtable/repository/FoodCategoryRepository.java new file mode 100644 index 0000000..5ad9678 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/FoodCategoryRepository.java @@ -0,0 +1,7 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.food.FoodCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FoodCategoryRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/repository/GroupRepository.java b/src/main/java/com/stcom/smartmealtable/repository/GroupRepository.java new file mode 100644 index 0000000..5424251 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/GroupRepository.java @@ -0,0 +1,11 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.group.Group; +import java.util.List; +import org.springframework.data.domain.Limit; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GroupRepository extends JpaRepository { + + List findByNameContaining(String name, Limit limit); +} diff --git a/src/main/java/com/stcom/smartmealtable/repository/MemberCategoryPreferenceRepository.java b/src/main/java/com/stcom/smartmealtable/repository/MemberCategoryPreferenceRepository.java new file mode 100644 index 0000000..1688aa3 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/MemberCategoryPreferenceRepository.java @@ -0,0 +1,14 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface MemberCategoryPreferenceRepository extends JpaRepository { + + @Query("select mcp from MemberCategoryPreference mcp where mcp.memberProfile.id = :memberProfileId order by mcp.priority asc") + List findDefaultByMemberProfileId(Long memberProfileId); + + void deleteByMemberProfile_Id(Long memberProfileId); +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/repository/MemberProfileRepository.java b/src/main/java/com/stcom/smartmealtable/repository/MemberProfileRepository.java new file mode 100644 index 0000000..1a22927 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/MemberProfileRepository.java @@ -0,0 +1,20 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MemberProfileRepository extends JpaRepository { + + @Query("select mp from MemberProfile mp where mp.member.id = :memberId") + Optional findMemberProfileByMemberId(@Param("memberId") Long memberId); + + void deleteMemberProfileByMember(Member member); + + @EntityGraph(attributePaths = {"member", "addressHistory, group"}) + Optional findMemberProfileEntityGraphById(Long id); +} diff --git a/src/main/java/com/stcom/smartmealtable/repository/MemberRepository.java b/src/main/java/com/stcom/smartmealtable/repository/MemberRepository.java new file mode 100644 index 0000000..c424179 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/MemberRepository.java @@ -0,0 +1,11 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.member.Member; +import jakarta.validation.constraints.Email; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(@Email String email); +} diff --git a/src/main/java/com/stcom/smartmealtable/repository/SocialAccountRepository.java b/src/main/java/com/stcom/smartmealtable/repository/SocialAccountRepository.java new file mode 100644 index 0000000..1db8947 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/SocialAccountRepository.java @@ -0,0 +1,40 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SocialAccountRepository extends JpaRepository { + + Optional findByProviderAndProviderUserId(String provider, String providerUserId); + + @Query("select sa.member.id from SocialAccount sa where sa.provider = :provider and sa.providerUserId = :providerUserId") + Optional findMemberIdByProviderAndProviderUserId( + @Param("provider") String provider, + @Param("providerUserId") String providerUserId + ); + + List findAllByMemberId(Long memberId); + + void deleteSocialAccountByMember(Member member); + + /** + * 소셜 계정(provider, providerUserId)에 연결된 MemberProfile의 ID만 조회 + */ + @Query(""" + select p.id + from SocialAccount sa + join sa.member m + join m.memberProfile p + where sa.provider = :provider + and sa.providerUserId = :providerUserId + """) + Optional findProfileIdByProviderAndProviderUserId( + @Param("provider") String provider, + @Param("providerUserId") String providerUserId + ); +} diff --git a/src/main/java/com/stcom/smartmealtable/repository/TermAgreementRepository.java b/src/main/java/com/stcom/smartmealtable/repository/TermAgreementRepository.java new file mode 100644 index 0000000..7f4e06f --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/TermAgreementRepository.java @@ -0,0 +1,7 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.term.TermAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TermAgreementRepository extends JpaRepository { +} diff --git a/src/main/java/com/stcom/smartmealtable/repository/TermRepository.java b/src/main/java/com/stcom/smartmealtable/repository/TermRepository.java new file mode 100644 index 0000000..4a4fe2a --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/repository/TermRepository.java @@ -0,0 +1,7 @@ +package com.stcom.smartmealtable.repository; + +import com.stcom.smartmealtable.domain.term.Term; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TermRepository extends JpaRepository { +} diff --git a/src/main/java/com/stcom/smartmealtable/security/JwtBlacklistService.java b/src/main/java/com/stcom/smartmealtable/security/JwtBlacklistService.java new file mode 100644 index 0000000..655d7d7 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/security/JwtBlacklistService.java @@ -0,0 +1,55 @@ +package com.stcom.smartmealtable.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class JwtBlacklistService { + + private final RedisTemplate redisTemplate; + + @Value("${jwt.secret}") + private String jwtSecret; + + private static final String BLACKLIST_PREFIX = "jwt:blacklist:"; + + + public void addToBlacklist(String token) { + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); + } + + Claims claims = Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))) + .build() + .parseClaimsJws(token) + .getBody(); + + Date expiration = claims.getExpiration(); + long ttl = expiration.getTime() - System.currentTimeMillis(); + + String key = BLACKLIST_PREFIX + token; + redisTemplate.opsForValue().set(key, "blacklisted", ttl, TimeUnit.MILLISECONDS); + log.info("토큰이 블랙리스트에 추가되었습니다. 만료 시간: {}", expiration); + } + + public boolean isBlacklisted(String token) { + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); + } + + String key = BLACKLIST_PREFIX + token; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/security/JwtTokenService.java b/src/main/java/com/stcom/smartmealtable/security/JwtTokenService.java new file mode 100644 index 0000000..095335e --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/security/JwtTokenService.java @@ -0,0 +1,96 @@ +package com.stcom.smartmealtable.security; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; +import com.stcom.smartmealtable.repository.MemberRepository; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JwtTokenService { + + private final MemberRepository memberRepository; + private final JwtBlacklistService jwtBlacklistService; + + @Value("${jwt.secret}") + private String jwtSecret; + + public String createAccessToken(Long memberId, Long profileId) { + return createToken(String.valueOf(memberId), profileId, 1000 * 60 * 60); + } + + public String createRefreshToken(Long memberId, Long profileId) { + return createToken(String.valueOf(memberId), profileId, 1000 * 60 * 60 * 24 * 14); + } + + private String createToken(String memberId, Long profileId, long expireTime) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + expireTime); + + Member member = memberRepository.findById(Long.parseLong(memberId)) + .orElseThrow(() -> new RuntimeException("존재하지 않는 회원입니다")); + + Map claims = new HashMap<>(); + claims.put("memberId", memberId); + claims.put("email", member.getEmail()); + if (profileId != null) { + claims.put("profileId", String.valueOf(profileId)); + } + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) + .compact(); + } + + public JwtTokenResponseDto createTokenDto(Long memberId, Long profileId) { + return new JwtTokenResponseDto( + createAccessToken(memberId, profileId), + createRefreshToken(memberId, profileId), + 3600, + "Bearer" + ); + } + + public void validateToken(String token) { + // Bearer 접두사 제거 + if (token.startsWith("Bearer ")) { + token = token.substring(7); + } + + if (jwtBlacklistService.isBlacklisted(token)) { + throw new IllegalArgumentException("블랙리스트에 추가된 토큰으로 접근하였습니다"); + } + + // 토큰 검증 + Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))) + .build() + .parseClaimsJws(token); + } + + public Claims extractClaims(String token) { + // Bearer 접두사 제거 + if (token.startsWith("Bearer ")) { + token = token.substring(7); + } + + return Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/service/BudgetService.java b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java new file mode 100644 index 0000000..14661cc --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/BudgetService.java @@ -0,0 +1,48 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.BudgetRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BudgetService { + + private final BudgetRepository budgetRepository; + private final MemberProfileRepository memberProfileRepository; + + public DailyBudget findRecentDailyBudgetByMemberProfileId(Long memberProfileId) { + return budgetRepository.findFirstDailyBudgetByMemberProfileId(memberProfileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필로 접근")); + } + + public MonthlyBudget findRecentMonthlyBudgetByMemberProfileId(Long memberProfileId) { + return budgetRepository.findFirstMonthlyBudgetByMemberProfileId(memberProfileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필로 접근")); + } + + @Transactional + public void saveMonthlyBudgetCustom(Long memberProfileId, Long limit) { + MemberProfile profile = memberProfileRepository.findById(memberProfileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필로 접근")); + MonthlyBudget monthlyBudget = new MonthlyBudget(profile, BigDecimal.valueOf(limit), YearMonth.now()); + budgetRepository.save(monthlyBudget); + } + + @Transactional + public void saveDailyBudgetCustom(Long memberProfileId, Long limit) { + MemberProfile profile = memberProfileRepository.findById(memberProfileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필로 접근")); + DailyBudget dailyBudget = new DailyBudget(profile, BigDecimal.valueOf(limit), LocalDate.now()); + budgetRepository.save(dailyBudget); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/service/GroupService.java b/src/main/java/com/stcom/smartmealtable/service/GroupService.java new file mode 100644 index 0000000..055cd74 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/GroupService.java @@ -0,0 +1,9 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.group.Group; +import java.util.List; + +public interface GroupService { + Group findGroupByGroupId(Long groupId); + List findGroupsByKeyword(String keyword); +} diff --git a/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java b/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java new file mode 100644 index 0000000..9fccfd8 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/GroupServiceImpl.java @@ -0,0 +1,28 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.repository.GroupRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Limit; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GroupServiceImpl implements GroupService { + + private final GroupRepository groupRepository; + + @Override + public Group findGroupByGroupId(Long groupId) { + return groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다")); + } + + @Override + public List findGroupsByKeyword(String keyword) { + return groupRepository.findByNameContaining(keyword, Limit.of(10)); + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/service/LoginService.java b/src/main/java/com/stcom/smartmealtable/service/LoginService.java new file mode 100644 index 0000000..05ccbf2 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/LoginService.java @@ -0,0 +1,68 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import com.stcom.smartmealtable.service.dto.AuthResultDto; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LoginService { + + private final MemberRepository memberRepository; + private final SocialAccountRepository socialAccountRepository; + + @Transactional + public AuthResultDto loginWithEmail(String email, String password) throws PasswordFailedExceededException { + Member findMember = memberRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + if (!findMember.isMatchedPassword(password)) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다"); + } + boolean newUser = findMember.isProfileRegistered(); + Long profileId = newUser ? null : findMember.getMemberProfile().getId(); + return new AuthResultDto(findMember.getId(), profileId, newUser); + } + + @Transactional + public AuthResultDto socialLogin(TokenDto token) { + Member member = memberRepository.findByEmail(token.getEmail()) + .orElseGet(() -> memberRepository.save(new Member(token.getEmail()))); + + SocialAccount sa = socialAccountRepository.findByProviderAndProviderUserId( + token.getProvider(), token.getProviderUserId()) + .map(existing -> { + existing.updateToken( + token.getAccessToken(), + token.getRefreshToken(), + LocalDateTime.now().plusSeconds(token.getExpiresIn())); + return existing; + }) + .orElseGet(() -> socialAccountRepository.save( + SocialAccount.builder() + .member(member) + .provider(token.getProvider()) + .providerUserId(token.getProviderUserId()) + .tokenType(token.getTokenType()) + .accessToken(token.getAccessToken()) + .refreshToken(token.getRefreshToken()) + .tokenExpiresAt(LocalDateTime.now().plusSeconds(token.getExpiresIn())) + .build() + )); + + Long profileId = socialAccountRepository + .findProfileIdByProviderAndProviderUserId(token.getProvider(), token.getProviderUserId()) + .orElse(null); + boolean newUser = (profileId == null); + return new AuthResultDto(member.getId(), profileId, newUser); + } + +} diff --git a/src/main/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceService.java b/src/main/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceService.java new file mode 100644 index 0000000..f028d68 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceService.java @@ -0,0 +1,61 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.food.FoodCategory; +import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; +import com.stcom.smartmealtable.domain.food.PreferenceType; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.FoodCategoryRepository; +import com.stcom.smartmealtable.repository.MemberCategoryPreferenceRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberCategoryPreferenceService { + + private final MemberCategoryPreferenceRepository preferenceRepository; + private final FoodCategoryRepository categoryRepository; + private final MemberProfileRepository profileRepository; + + @Transactional + public void savePreferences(Long profileId, List liked, List disliked) { + MemberProfile profile = profileRepository.findById(profileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + + preferenceRepository.deleteByMemberProfile_Id(profileId); + + int priority = 1; + for (Long catId : liked) { + FoodCategory category = categoryRepository.findById(catId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 카테고리입니다: " + catId)); + MemberCategoryPreference pref = MemberCategoryPreference.builder() + .memberProfile(profile) + .category(category) + .type(PreferenceType.LIKE) + .priority(priority++) + .build(); + preferenceRepository.save(pref); + } + + priority = 1; + for (Long catId : disliked) { + FoodCategory category = categoryRepository.findById(catId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 카테고리입니다: " + catId)); + MemberCategoryPreference pref = MemberCategoryPreference.builder() + .memberProfile(profile) + .category(category) + .type(PreferenceType.DISLIKE) + .priority(priority++) + .build(); + preferenceRepository.save(pref); + } + } + + public List getPreferences(Long profileId) { + return preferenceRepository.findDefaultByMemberProfileId(profileId); + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java b/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java new file mode 100644 index 0000000..8176c88 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/MemberProfileService.java @@ -0,0 +1,114 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressEntity; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.repository.AddressEntityRepository; +import com.stcom.smartmealtable.repository.GroupRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberProfileService { + + private final MemberProfileRepository memberProfileRepository; + private final GroupRepository groupRepository; + private final MemberRepository memberRepository; + private final AddressEntityRepository addressEntityRepository; + + public MemberProfile getProfileFetch(Long profileId) { + return memberProfileRepository.findMemberProfileEntityGraphById(profileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + } + + @Transactional + public void createProfile(String nickName, Long memberId, MemberType type, Long groupId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다")); + + // TODO: 실제 프록시 객체 초기화 시점에 인스턴스의 서브타입이 결정된다는데, 테스트해보기 + Group group = (groupId != null) + ? groupRepository.getReferenceById(groupId) + : null; + + MemberProfile profile = MemberProfile.builder() + .nickName(nickName) + .member(member) + .type(type) + .group(group) + .build(); + + memberProfileRepository.save(profile); + } + + @Transactional + public void changeProfile(Long profileId, String nickName, MemberType type, Long groupId) { + MemberProfile profile = memberProfileRepository.findById(profileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + + profile.changeNickName(nickName); + profile.changeMemberType(type); + Group newGroup = (groupId != null) + ? groupRepository.getReferenceById(groupId) + : null; + profile.changeGroup(newGroup); + } + + @Transactional + public void changeAddressToPrimary(Long profileId, Long addressId) { + MemberProfile profile = memberProfileRepository.findById(profileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + AddressEntity targetAddressEntity = addressEntityRepository.findById(addressId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원 주소 정보입니다.")); + profile.setPrimaryAddress(targetAddressEntity); + } + + @Transactional + public void saveNewAddress(Long profileId, Address address, String alias, AddressType addressType) { + MemberProfile profile = memberProfileRepository.findById(profileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + AddressEntity addressEntity = AddressEntity.builder() + .address(address) + .alias(alias) + .type(addressType) + .build(); + addressEntityRepository.save(addressEntity); + profile.addAddress(addressEntity); + } + + @Transactional + public void changeAddress(Long profileId, Long addressEntityId, Address address, String alias, + AddressType addressType) { + MemberProfile profile = memberProfileRepository.findById(profileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + AddressEntity addressEntity = addressEntityRepository.findById(addressEntityId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원 주소 정보입니다.")); + profile.changeAddress(addressEntity, address, alias, addressType); + } + + @Transactional + public void deleteAddress(Long profileId, Long addressEntityId) { + MemberProfile profile = memberProfileRepository.findById(profileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + AddressEntity addressEntity = addressEntityRepository.findById(addressEntityId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원 주소 정보입니다.")); + profile.removeAddress(addressEntity); + } + + @Transactional + public void registerDefaultBudgets(Long profileId, Long dailyLimit, Long monthlyLimit) { + MemberProfile profile = memberProfileRepository.findById(profileId) + .orElseThrow(() -> new IllegalStateException("존재하지 않는 프로필입니다")); + profile.registerDefaultBudgets(BigDecimal.valueOf(dailyLimit), BigDecimal.valueOf(monthlyLimit)); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/service/MemberService.java b/src/main/java/com/stcom/smartmealtable/service/MemberService.java new file mode 100644 index 0000000..a358e1c --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/MemberService.java @@ -0,0 +1,67 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.repository.AddressEntityRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final MemberProfileRepository memberProfileRepository; + private final SocialAccountRepository socialAccountRepository; + private final AddressEntityRepository addressEntityRepository; + + /** + * 이메일 중복 검사를 수행합니다. + * 동일한 이메일을 가진 회원이 이미 존재하면 예외를 발생시킵니다. + * + * @param email 중복 검사할 이메일 + * @throws IllegalArgumentException 이메일이 이미 존재하는 경우 + */ + public void validateDuplicatedEmail(String email) { + memberRepository.findByEmail(email).ifPresent(member -> { + throw new IllegalArgumentException("이미 존재하는 이메일 입니다"); + }); + } + + @Transactional + public void saveMember(Member member) { + memberRepository.save(member); + } + + public Member findMemberByMemberId(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다")); + } + + public void checkPasswordDoubly(String password, String confirmPassword) { + if (!password.equals(confirmPassword)) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다"); + } + } + + public void changePassword(Long memberId, String originPassword, String newPassword) + throws PasswordFailedExceededException, PasswordPolicyException { + Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalStateException("회원이 존재하지 않습니다")); + findMember.changePassword(originPassword, newPassword); + } + + public void deleteByMemberId(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalStateException("회원이 존재하지 않습니다")); + memberProfileRepository.deleteMemberProfileByMember(member); + socialAccountRepository.deleteSocialAccountByMember(member); + memberRepository.delete(member); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/service/SocialAccountService.java b/src/main/java/com/stcom/smartmealtable/service/SocialAccountService.java new file mode 100644 index 0000000..ce16baf --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/SocialAccountService.java @@ -0,0 +1,80 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SocialAccountService { + + private final SocialAccountRepository socialAccountRepository; + private final MemberRepository memberRepository; + + @Transactional + public void createNewMemberAndLinkSocialAccount(TokenDto tokenDto) { + Member member = new Member(tokenDto.getEmail()); + memberRepository.save(member); + + SocialAccount socialAccount = SocialAccount.builder() + .member(member) + .provider(tokenDto.getProvider()) + .providerUserId(tokenDto.getProviderUserId()) + .tokenType(tokenDto.getTokenType()) + .accessToken(tokenDto.getAccessToken()) + .refreshToken(tokenDto.getRefreshToken()) + .tokenExpiresAt(LocalDateTime.now().plusSeconds(tokenDto.getExpiresIn())) + .build(); + socialAccountRepository.save(socialAccount); + } + + @Transactional + public void linkSocialAccount(TokenDto tokenDto) { + Member member = memberRepository.findByEmail(tokenDto.getEmail()) + .orElseThrow(() -> new IllegalStateException("회원이 null일 수는 없습니다")); + + SocialAccount socialAccount = SocialAccount.builder() + .member(member) + .provider(tokenDto.getProvider()) + .providerUserId(tokenDto.getProviderUserId()) + .tokenType(tokenDto.getTokenType()) + .accessToken(tokenDto.getAccessToken()) + .refreshToken(tokenDto.getRefreshToken()) + .tokenExpiresAt(LocalDateTime.now().plusSeconds(tokenDto.getExpiresIn())) + .build(); + socialAccountRepository.save(socialAccount); + } + + public SocialAccount findSocialAccount(String provider, String providerUserId) { + return socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId).orElse(null); + } + + public boolean isNewUser(String provider, String providerUserId) { + return socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId).isEmpty(); + } + + @Transactional + public void updateToken(Long socialAccountId, String accessToken, String refreshToken, + LocalDateTime tokenExpiresAt) { + SocialAccount socialAccount = socialAccountRepository.findById(socialAccountId) + .orElseThrow(() -> new IllegalStateException("확인되지 않은 계정입니다")); + socialAccount.updateToken(accessToken, refreshToken, tokenExpiresAt); + } + + public List findAllProviders(Long memberId) { + List accounts = socialAccountRepository.findAllByMemberId(memberId); + return accounts.stream() + .map(SocialAccount::getProvider) + .toList(); + } + + +} diff --git a/src/main/java/com/stcom/smartmealtable/service/TermService.java b/src/main/java/com/stcom/smartmealtable/service/TermService.java new file mode 100644 index 0000000..6e8e401 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/TermService.java @@ -0,0 +1,62 @@ +package com.stcom.smartmealtable.service; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.term.Term; +import com.stcom.smartmealtable.domain.term.TermAgreement; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.TermAgreementRepository; +import com.stcom.smartmealtable.repository.TermRepository; +import com.stcom.smartmealtable.service.dto.TermAgreementRequestDto; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TermService { + + private final TermRepository termRepository; + private final MemberRepository memberRepository; + private final TermAgreementRepository termAgreementRepository; + + public List findAll() { + return termRepository.findAll(); + } + + + public void agreeTerms(Long memberId, List termAgreements) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다")); + + List requiredTerms = termRepository.findAll().stream() + .filter(Term::getIsRequired) + .toList(); + + Map agreementMap = termAgreements.stream() + .collect(Collectors.toMap(TermAgreementRequestDto::getTermId, + TermAgreementRequestDto::getIsAgreed)); + + for (Term term : requiredTerms) { + Boolean agreed = agreementMap.get(term.getId()); + if (agreed == null || !agreed) { + throw new IllegalArgumentException("필수 약관에 동의해야 합니다: " + term.getTitle()); + } + } + + // 약관 동의 저장 + for (TermAgreementRequestDto dto : termAgreements) { + Term term = termRepository.findById(dto.getTermId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 약관입니다: " + dto.getTermId())); + TermAgreement agreement = TermAgreement.builder() + .member(member) + .term(term) + .isAgreed(dto.getIsAgreed()) + .build(); + termAgreementRepository.save(agreement); + } + } +} diff --git a/src/main/java/com/stcom/smartmealtable/service/dto/AuthResultDto.java b/src/main/java/com/stcom/smartmealtable/service/dto/AuthResultDto.java new file mode 100644 index 0000000..0f96625 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/dto/AuthResultDto.java @@ -0,0 +1,12 @@ +package com.stcom.smartmealtable.service.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AuthResultDto { + private Long memberId; + private Long profileId; + private boolean newUser; +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/service/dto/MemberDto.java b/src/main/java/com/stcom/smartmealtable/service/dto/MemberDto.java new file mode 100644 index 0000000..8603826 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/dto/MemberDto.java @@ -0,0 +1,24 @@ +package com.stcom.smartmealtable.service.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberDto { + private Long memberId; + private Long profileId; + private String email; + + public static MemberDto createFrom(Long memberId, Long profileId, String email) { + return MemberDto.builder() + .memberId(memberId) + .profileId(profileId) + .email(email) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/stcom/smartmealtable/service/dto/TermAgreementRequestDto.java b/src/main/java/com/stcom/smartmealtable/service/dto/TermAgreementRequestDto.java new file mode 100644 index 0000000..0a5583d --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/service/dto/TermAgreementRequestDto.java @@ -0,0 +1,11 @@ +package com.stcom.smartmealtable.service.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TermAgreementRequestDto { + private Long termId; + private Boolean isAgreed; +} diff --git a/src/main/java/com/stcom/smartmealtable/web/WebConfig.java b/src/main/java/com/stcom/smartmealtable/web/WebConfig.java new file mode 100644 index 0000000..0be154d --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/WebConfig.java @@ -0,0 +1,39 @@ +package com.stcom.smartmealtable.web; + +import com.stcom.smartmealtable.web.argumentresolver.UserContextArgumentResolver; +import com.stcom.smartmealtable.web.interceptor.JwtAuthenticationInterceptor; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final UserContextArgumentResolver userContextArgumentResolver; + private final JwtAuthenticationInterceptor jwtAuthenticationInterceptor; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(userContextArgumentResolver); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(jwtAuthenticationInterceptor) + .addPathPatterns("/api/**"); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://localhost:3000", "http://localhost:5173") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/web/argumentresolver/UserContext.java b/src/main/java/com/stcom/smartmealtable/web/argumentresolver/UserContext.java new file mode 100644 index 0000000..0fa44db --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/argumentresolver/UserContext.java @@ -0,0 +1,15 @@ +package com.stcom.smartmealtable.web.argumentresolver; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface UserContext { + + boolean required() default true; +} diff --git a/src/main/java/com/stcom/smartmealtable/web/argumentresolver/UserContextArgumentResolver.java b/src/main/java/com/stcom/smartmealtable/web/argumentresolver/UserContextArgumentResolver.java new file mode 100644 index 0000000..47d14e9 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/argumentresolver/UserContextArgumentResolver.java @@ -0,0 +1,66 @@ +package com.stcom.smartmealtable.web.argumentresolver; + +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +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; + +@Component +@RequiredArgsConstructor +public class UserContextArgumentResolver implements HandlerMethodArgumentResolver { + + private final JwtTokenService jwtTokenService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserContext.class) && + MemberDto.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest httpServletRequest = webRequest.getNativeRequest(HttpServletRequest.class); + UserContext annotation = parameter.getParameterAnnotation(UserContext.class); + + if (httpServletRequest == null) { + throw new RuntimeException("예상치 못한 오류 발생."); + } + + String token = httpServletRequest.getHeader("Authorization"); + if (token == null || token.trim().isEmpty()) { + throw new RuntimeException("권한 없음."); + } + + return extractUserContext(token); + } + + private MemberDto extractUserContext(String token) { + if (token.startsWith("Bearer ")) { + token = token.substring(7); + } + + var claims = jwtTokenService.extractClaims(token); + + String memberIdStr = claims.get("memberId", String.class); + Long memberId = Long.parseLong(memberIdStr); + + // profileId 추출 (있는 경우) + Long profileId = null; + if (claims.containsKey("profileId")) { + String profileIdStr = claims.get("profileId", String.class); + profileId = Long.parseLong(profileIdStr); + } + + // email 추출 (있는 경우) + String email = claims.containsKey("email") ? claims.get("email", String.class) : null; + + return new MemberDto(memberId, profileId, email); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java new file mode 100644 index 0000000..b7e14fd --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/GroupController.java @@ -0,0 +1,46 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.service.GroupService; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/groups") +public class GroupController { + + private final GroupService groupService; + + @GetMapping() + public ApiResponse> searchGroup(@RequestParam String keyword) { + if (keyword.isBlank()) { + return ApiResponse.createError("키워드가 비어있습니다. 키워드를 입력해주세요"); + } + List result = groupService.findGroupsByKeyword(keyword); + return ApiResponse.createSuccess(result.stream() + .map(GroupDto::new) + .toList()); + } + + @Data + @AllArgsConstructor + static class GroupDto { + private String roadAddress; + private String name; + private String groupType; + + public GroupDto(Group group) { + this.groupType = group.getTypeName(); + this.name = group.getName(); + this.roadAddress = group.getAddress().getRoadAddress(); + } + } +} diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/LoginController.java b/src/main/java/com/stcom/smartmealtable/web/controller/LoginController.java new file mode 100644 index 0000000..5a6103c --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/LoginController.java @@ -0,0 +1,61 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; +import com.stcom.smartmealtable.security.JwtBlacklistService; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.LoginService; +import com.stcom.smartmealtable.service.dto.AuthResultDto; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/v1/auth") +public class LoginController { + + private final LoginService loginService; + private final JwtTokenService jwtTokenService; + private final JwtBlacklistService jwtBlacklistService; + + @PostMapping("/login") + public ApiResponse login(@Validated @RequestBody LoginRequest request) + throws PasswordFailedExceededException { + AuthResultDto authResultDto = loginService.loginWithEmail(request.getEmail(), request.getPassword()); + JwtTokenResponseDto jwtTokenResponseDto = + jwtTokenService.createTokenDto(authResultDto.getMemberId(), authResultDto.getProfileId()); + if (authResultDto.isNewUser()) { + jwtTokenResponseDto.setNewUser(true); + } + return ApiResponse.createSuccess(jwtTokenResponseDto); + } + + @PostMapping("/logout") + public ApiResponse logout(HttpServletRequest request) { + String jwt = request.getHeader("Authorization"); + jwtBlacklistService.addToBlacklist(jwt); + return ApiResponse.createSuccessWithNoContent(); + } + + @Data + static class LoginRequest { + + @NotEmpty + @Email + private String email; + + @NotEmpty + private String password; + } +} diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java new file mode 100644 index 0000000..dd3053e --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberController.java @@ -0,0 +1,129 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.MemberService; +import com.stcom.smartmealtable.service.TermService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.service.dto.TermAgreementRequestDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/v1/members") +public class MemberController { + + private final MemberService memberService; + private final JwtTokenService jwtTokenService; + private final TermService termService; + + @GetMapping("/email/check") + public ResponseEntity> checkEmail(@Email @RequestParam String email) { + memberService.validateDuplicatedEmail(email); + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping() + public ApiResponse createMember(@Valid @RequestBody CreateMemberRequest request, + BindingResult bindingResult) throws PasswordPolicyException { + memberService.validateDuplicatedEmail(request.getEmail()); + memberService.checkPasswordDoubly(request.getPassword(), request.getConfirmPassword()); + + Member member = Member.builder() + .fullName(request.getFullName()) + .email(request.getEmail()) + .rawPassword(request.getPassword()) + .build(); + + memberService.saveMember(member); + JwtTokenResponseDto tokenDto = jwtTokenService.createTokenDto(member.getId(), null); + tokenDto.setNewUser(true); + return ApiResponse.createSuccess(tokenDto); + } + + @PatchMapping("/me") + public ApiResponse editMember(@UserContext MemberDto memberDto, @Valid @RequestBody EditMemberRequest request, + BindingResult bindingResult) + throws PasswordPolicyException, PasswordFailedExceededException { + memberService.checkPasswordDoubly(request.getNewPassword(), request.getConfirmPassword()); + memberService.changePassword(memberDto.getMemberId(), request.getOriginPassword(), request.getNewPassword()); + return ApiResponse.createSuccessWithNoContent(); + } + + @DeleteMapping("/me") + public ApiResponse deleteMember(@UserContext MemberDto memberDto) { + memberService.deleteByMemberId(memberDto.getMemberId()); + return ApiResponse.createSuccessWithNoContent(); + } + + @PostMapping("/signup") + public ApiResponse signUpWithTermAgreement(@UserContext MemberDto memberDto, + @RequestBody List agreements) { + termService.agreeTerms( + memberDto.getMemberId(), + agreements.stream() + .map(dto -> new TermAgreementRequestDto(dto.getTermId(), dto.getIsAgreed())) + .toList() + ); + return ApiResponse.createSuccessWithNoContent(); + } + + @DeleteMapping("/signup") + public ApiResponse signUpCancel(@UserContext MemberDto memberDto) { + memberService.deleteByMemberId(memberDto.getMemberId()); + return ApiResponse.createSuccessWithNoContent(); + } + + @Data + @AllArgsConstructor + static class CreateMemberRequest { + + @Email + private String email; + private String password; + private String confirmPassword; + private String fullName; + } + + @Data + @AllArgsConstructor + static class EditMemberRequest { + + private String originPassword; + private String newPassword; + private String confirmPassword; + } + + @Data + @AllArgsConstructor + static class TermAgreementDto { + private Long termId; + private Boolean isAgreed; + } + +} diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java new file mode 100644 index 0000000..a2ad848 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/MemberProfileController.java @@ -0,0 +1,203 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; +import com.stcom.smartmealtable.domain.food.PreferenceType; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.infrastructure.AddressApiService; +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; +import com.stcom.smartmealtable.service.BudgetService; +import com.stcom.smartmealtable.service.MemberCategoryPreferenceService; +import com.stcom.smartmealtable.service.MemberProfileService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members/profile") +public class MemberProfileController { + + private final MemberProfileService memberProfileService; + private final AddressApiService addressApiService; + private final MemberCategoryPreferenceService memberCategoryPreferenceService; + private final BudgetService budgetService; + + @GetMapping("/me") + public ApiResponse getMemberProfilePageInfo(@UserContext MemberDto memberDto) { + MemberProfile profile = memberProfileService.getProfileFetch(memberDto.getProfileId()); + return ApiResponse.createSuccess(new MemberProfilePageResponse(profile, memberDto)); + } + + @PostMapping() + public ApiResponse createMemberProfile(@UserContext MemberDto memberDto, + @Validated @RequestBody MemberProfileRequest request) { + memberProfileService.createProfile(request.getNickName(), memberDto.getMemberId(), request.getMemberType(), + request.getGroupId()); + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/me") + public ApiResponse changeMemberProfile(@UserContext MemberDto memberDto, + @Validated @RequestBody MemberProfileRequest request) { + memberProfileService.changeProfile(memberDto.getProfileId(), request.getNickName(), request.getMemberType(), + request.getGroupId()); + return ApiResponse.createSuccessWithNoContent(); + } + + @PostMapping("/me/addresses/{id}/primary") + public ApiResponse changePrimaryAddress(@UserContext MemberDto memberDto, @PathVariable("id") Long addressId) { + memberProfileService.changeAddressToPrimary(memberDto.getProfileId(), addressId); + return ApiResponse.createSuccessWithNoContent(); + } + + @PostMapping("/me/addresses") + public ApiResponse registerAddress(@UserContext MemberDto memberDto, AddressCURequest request) { + Address address = addressApiService.createAddressFromRequest(request.toAddressApiRequest()); + memberProfileService.saveNewAddress(memberDto.getProfileId(), address, request.getAlias(), + request.getAddressType()); + return ApiResponse.createSuccessWithNoContent(); + } + + @PatchMapping("/me/addresses/{id}") + public ApiResponse changeAddress(@UserContext MemberDto memberDto, @PathVariable("id") Long addressId, + AddressCURequest request) { + Address address = addressApiService.createAddressFromRequest(request.toAddressApiRequest()); + memberProfileService.changeAddress(memberDto.getProfileId(), addressId, address, request.getAlias(), + request.getAddressType()); + return ApiResponse.createSuccessWithNoContent(); + } + + @DeleteMapping("/me/addresses/{id}") + public ApiResponse deleteAddress(@UserContext MemberDto memberDto, @PathVariable("id") Long addressId) { + memberProfileService.deleteAddress(memberDto.getProfileId(), addressId); + return ApiResponse.createSuccessWithNoContent(); + } + + @GetMapping("/me/preferences") + public ApiResponse getCategoryPreferences(@UserContext MemberDto memberDto) { + List preferences = + memberCategoryPreferenceService.getPreferences(memberDto.getProfileId()); + + List liked = preferences.stream() + .filter(p -> p.getType() == PreferenceType.LIKE) + .map(p -> new CategoryPreferenceDto( + p.getCategory().getId(), + p.getCategory().getName(), + p.getPriority())) + .toList(); + + List disliked = preferences.stream() + .filter(p -> p.getType() == PreferenceType.DISLIKE) + .map(p -> new CategoryPreferenceDto( + p.getCategory().getId(), + p.getCategory().getName(), + p.getPriority())) + .toList(); + + return ApiResponse.createSuccess(new PreferencesResponse(liked, disliked)); + } + + @PostMapping("/me/preferences") + public ApiResponse saveCategoryPreferences(@UserContext MemberDto memberDto, + @RequestBody PreferencesRequest request) { + memberCategoryPreferenceService.savePreferences( + memberDto.getProfileId(), + request.getLiked(), + request.getDisliked()); + return ApiResponse.createSuccessWithNoContent(); + } + + @PostMapping("/me/budgets") + public ApiResponse createDefaultBudgets(@UserContext MemberDto memberDto, + @RequestBody BudgetRequest budgetRequest) { + memberProfileService.registerDefaultBudgets(memberDto.getProfileId(), budgetRequest.getDailyLimit(), + budgetRequest.getMonthlyLimit()); + return ApiResponse.createSuccessWithNoContent(); + } + + @AllArgsConstructor + @Data + static class MemberProfilePageResponse { + private String nickName; + private String email; + private MemberType memberType; + private String groupName; + private String primaryAddress; + + public MemberProfilePageResponse(MemberProfile profile, MemberDto memberDto) { + this.nickName = profile.getNickName(); + this.email = memberDto.getEmail(); + Address address = profile.findPrimaryAddress().getAddress(); + this.primaryAddress = address.getRoadAddress() + address.getDetailAddress(); + this.memberType = profile.getType(); + this.groupName = profile.getGroup().getName(); + } + } + + @AllArgsConstructor + @Data + static class MemberProfileRequest { + private String nickName; + private Long groupId; + private MemberType memberType; + } + + @AllArgsConstructor + @Data + static class AddressCURequest { + private String roadAddress; + private AddressType addressType; + private String alias; + private String detailAddress; + + public AddressRequest toAddressApiRequest() { + return new AddressRequest(roadAddress, alias, detailAddress); + } + } + + @AllArgsConstructor + @Data + static class PreferencesRequest { + private List liked; + private List disliked; + } + + @AllArgsConstructor + @Data + static class PreferencesResponse { + private List liked; + private List disliked; + } + + @AllArgsConstructor + @Data + static class CategoryPreferenceDto { + private Long categoryId; + private String categoryName; + private Integer priority; + } + + @AllArgsConstructor + @Data + static class BudgetRequest { + private Long dailyLimit; + private Long monthlyLimit; + } + +} diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/OAuth2Controller.java b/src/main/java/com/stcom/smartmealtable/web/controller/OAuth2Controller.java new file mode 100644 index 0000000..d25461d --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/OAuth2Controller.java @@ -0,0 +1,82 @@ +package com.stcom.smartmealtable.web.controller; + + +import com.stcom.smartmealtable.infrastructure.SocialAuthService; +import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.LoginService; +import com.stcom.smartmealtable.service.dto.AuthResultDto; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Slf4j +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class OAuth2Controller { + + private final JwtTokenService jwtTokenService; + private final SocialAuthService socialAuthService; + private final LoginService loginService; + + @PostMapping("/oauth2/code") + public ApiResponse getTokenFromSocial(@RequestBody JwtTokenRequest request) { + TokenDto token = socialAuthService.getTokenResponse( + request.getProvider().toLowerCase(), request.getAuthorizationCode()); + AuthResultDto authResultDto = loginService.socialLogin(token); + JwtTokenResponseDto jwtDto = + jwtTokenService.createTokenDto(authResultDto.getMemberId(), authResultDto.getProfileId()); + if (authResultDto.isNewUser()) { + jwtDto.setNewUser(true); + } + return ApiResponse.createSuccess(jwtDto); + } + + @PostMapping("/token/refresh") + public ApiResponse refreshAccessToken(@UserContext MemberDto memberDto, + @RequestBody JwtRefreshTokenRequest request) { + String accessToken = jwtTokenService.createAccessToken(memberDto.getMemberId(), memberDto.getProfileId()); + return ApiResponse.createSuccess( + new JwtRefreshedAccessTokenDto(accessToken, 3600, "Bearar") + ); + } + + + @Data + @AllArgsConstructor + static class JwtTokenRequest { + + @NotEmpty + private String provider; + + @NotEmpty + private String authorizationCode; + } + + @Data + @AllArgsConstructor + static class JwtRefreshedAccessTokenDto { + private String accessToken; + private int expiresIn; + private String tokenType; + } + + @Data + static class JwtRefreshTokenRequest { + + @NotEmpty + private String refreshToken; + } + +} diff --git a/src/main/java/com/stcom/smartmealtable/web/controller/TermController.java b/src/main/java/com/stcom/smartmealtable/web/controller/TermController.java new file mode 100644 index 0000000..91bedb8 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/controller/TermController.java @@ -0,0 +1,46 @@ +package com.stcom.smartmealtable.web.controller; + +import com.stcom.smartmealtable.domain.term.Term; +import com.stcom.smartmealtable.service.TermService; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/terms") +public class TermController { + + private final TermService termService; + + @GetMapping() + public ApiResponse> getTerms() { + List result = termService.findAll(); + return ApiResponse.createSuccess(result.stream() + .map(TermResponse::new) + .toList()); + } + + @Data + @AllArgsConstructor + static class TermResponse { + private Long termId; + private String title; + private String content; + private boolean isRequired; + + public TermResponse(Term term) { + this.termId = term.getId(); + this.title = term.getTitle(); + this.content = term.getContent(); + this.isRequired = term.getIsRequired(); + } + + } + +} diff --git a/src/main/java/com/stcom/smartmealtable/web/dto/ApiResponse.java b/src/main/java/com/stcom/smartmealtable/web/dto/ApiResponse.java new file mode 100644 index 0000000..a2bf618 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/dto/ApiResponse.java @@ -0,0 +1,54 @@ +package com.stcom.smartmealtable.web.dto; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; + +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ApiResponse { + + private static final String SUCCESS_STATUS = "SUCCESS"; + private static final String FAIL_STATUS = "FAIL"; + private static final String ERROR_STATUS = "ERROR"; + + private String status; + private String message; + private T data; + + public static ApiResponse createSuccess(T data) { + return new ApiResponse<>(SUCCESS_STATUS, null, data); + } + + public static ApiResponse createSuccessWithNoContent() { + return new ApiResponse<>(SUCCESS_STATUS, null, null); + } + + // Hibernate Validator에 의해 유효하지 않은 데이터로 인해 API 호출이 거부될때 반환 + public static ApiResponse> createFail(BindingResult bindingResult) { + Map errors = new HashMap<>(); + + List allErrors = bindingResult.getAllErrors(); + for (ObjectError error : allErrors) { + if (error instanceof FieldError) { + errors.put(((FieldError) error).getField(), error.getDefaultMessage()); + } else { + errors.put(error.getObjectName(), error.getDefaultMessage()); + } + } + return new ApiResponse<>(FAIL_STATUS, null, errors); + } + + // 예외 발생으로 API 호출 실패시 반환 + public static ApiResponse createError(String message) { + return new ApiResponse<>(ERROR_STATUS, message, null); + } + + +} diff --git a/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java b/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java new file mode 100644 index 0000000..8764a4e --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/exhandler/ExControllerAdvice.java @@ -0,0 +1,91 @@ +package com.stcom.smartmealtable.web.exhandler; + +import com.stcom.smartmealtable.exception.ExternApiStatusError; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.web.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; + +@Slf4j +@RestControllerAdvice +public class ExControllerAdvice { + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse processValidationError(MethodArgumentNotValidException exception) { + BindingResult bindingResult = exception.getBindingResult(); + return ApiResponse.createFail(bindingResult); + } + + @ExceptionHandler(HandlerMethodValidationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse processValidationValidatorError(HandlerMethodValidationException exception) { + return ApiResponse.createError(exception.getMessage()); + } + + + @ExceptionHandler(PasswordPolicyException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse passwordPolicyExHandler(PasswordPolicyException e) { + log.error("[PasswordPolicyException] ex", e); + return ApiResponse.createError(e.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse illegalArgumentExHandler(IllegalArgumentException e) { + log.error("[IllegalArgumentException] ex", e); + return ApiResponse.createError("파라미터나 API 스펙을 확인해보세요" + e.getMessage()); + } + + @ExceptionHandler(IllegalStateException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse illegalStateExHandler(IllegalArgumentException e) { + log.error("[IllegalStateException] ex", e); + return ApiResponse.createError("파라미터나 API 스펙을 확인해보세요" + e.getMessage()); + } + + @ExceptionHandler(PasswordFailedExceededException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ApiResponse passwordFailedExceededExHandler(PasswordFailedExceededException e) { + log.error("[PasswordFailedExceededException] ex", e); + return ApiResponse.createError(e.getMessage()); + } + + @ExceptionHandler(ExternApiStatusError.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse externApiStatusErrorHandler(ExternApiStatusError e) { + log.error("[ExternApiStatusError] ex", e); + return ApiResponse.createError("외부 API 호출 중 오류가 발생했습니다: " + e.getMessage()); + } + + @ExceptionHandler(HttpClientErrorException.BadRequest.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleHttpClientErrorBadRequest(HttpClientErrorException.BadRequest e) { + log.error("[HttpClientErrorException.BadRequest] ex", e); + String responseBody = e.getResponseBodyAsString(); + return ApiResponse.createError("외부 OAuth 인증 실패: 잘못된 인증 코드입니다. " + responseBody); + } + + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse runtimeExHandler(Exception e) { + log.error("[RuntimeException] ex", e); + return ApiResponse.createError("서버 내부에서 언체크 예외가 발생했습니다"); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse exHandler(Exception e) { + log.error("[Exception] ex", e); + return ApiResponse.createError("서버 내부에서 체크 예외가 발생했습니다"); + } +} diff --git a/src/main/java/com/stcom/smartmealtable/web/interceptor/JwtAuthenticationInterceptor.java b/src/main/java/com/stcom/smartmealtable/web/interceptor/JwtAuthenticationInterceptor.java new file mode 100644 index 0000000..b6e3c33 --- /dev/null +++ b/src/main/java/com/stcom/smartmealtable/web/interceptor/JwtAuthenticationInterceptor.java @@ -0,0 +1,51 @@ +package com.stcom.smartmealtable.web.interceptor; + +import com.stcom.smartmealtable.security.JwtTokenService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationInterceptor implements HandlerInterceptor { + + private final JwtTokenService jwtTokenService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 인증이 필요 없는 경로 제외 (필요에 따라 설정) + String path = request.getRequestURI(); + if (path.startsWith("/api/v1/auth/login") || + path.startsWith("/api/v1/auth/oauth2") || + path.startsWith("/api/v1/auth/token/refresh")) { + return true; + } + + // OPTIONS 요청은 CORS preflight 요청으로 인증 필요 없음 + if (request.getMethod().equals("OPTIONS")) { + return true; + } + + // 토큰 존재 확인 + String token = request.getHeader("Authorization"); + if (token == null || token.trim().isEmpty()) { + return true; // 토큰이 없는 요청은 통과시키고, ArgumentResolver에서 처리 + } + + // 토큰 검증 + try { + jwtTokenService.validateToken(token); + return true; + } catch (Exception e) { + log.error("토큰 검증 실패: {}", e.getMessage()); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + } + +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/config/TestWebConfig.java b/src/test/java/com/stcom/smartmealtable/config/TestWebConfig.java new file mode 100644 index 0000000..597b753 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/config/TestWebConfig.java @@ -0,0 +1,17 @@ +package com.stcom.smartmealtable.config; + +import com.stcom.smartmealtable.security.JwtTokenService; +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@TestConfiguration +public class TestWebConfig { + + @Bean + @Primary + public JwtTokenService jwtTokenService() { + return Mockito.mock(JwtTokenService.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/Address/AddressTest.java b/src/test/java/com/stcom/smartmealtable/domain/Address/AddressTest.java new file mode 100644 index 0000000..822ca03 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/Address/AddressTest.java @@ -0,0 +1,74 @@ +package com.stcom.smartmealtable.domain.Address; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class AddressTest { + + @Test + @DisplayName("Address 빌더를 통해 객체가 올바르게 생성된다") + void addressIsCreatedCorrectlyUsingBuilder() { + // given & when + Address address = Address.builder() + .lotNumberAddress("서울특별시 강남구 역삼동 123-45") + .roadAddress("서울특별시 강남구 테헤란로 123") + .detailAddress("4층 401호") + .latitude(37.5012) + .longitude(127.0396) + .build(); + + // then + assertThat(address.getLotNumberAddress()).isEqualTo("서울특별시 강남구 역삼동 123-45"); + assertThat(address.getRoadAddress()).isEqualTo("서울특별시 강남구 테헤란로 123"); + assertThat(address.getDetailAddress()).isEqualTo("4층 401호"); + assertThat(address.getLatitude()).isEqualTo(37.5012); + assertThat(address.getLongitude()).isEqualTo(127.0396); + } + + @Test + @DisplayName("updateAddress 메소드로 주소 정보를 업데이트할 수 있다") + void updateAddressMethodUpdatesAddressInformation() { + // given + Address address = Address.builder() + .lotNumberAddress("서울특별시 강남구 역삼동 123-45") + .roadAddress("서울특별시 강남구 테헤란로 123") + .detailAddress("4층 401호") + .latitude(37.5012) + .longitude(127.0396) + .build(); + + // when + address.updateAddress( + "서울특별시 서초구 서초동 987-65", + "서울특별시 서초구 서초대로 456", + "8층 802호", + 37.4923, + 127.0292 + ); + + // then + assertThat(address.getLotNumberAddress()).isEqualTo("서울특별시 서초구 서초동 987-65"); + assertThat(address.getRoadAddress()).isEqualTo("서울특별시 서초구 서초대로 456"); + assertThat(address.getDetailAddress()).isEqualTo("8층 802호"); + assertThat(address.getLatitude()).isEqualTo(37.4923); + assertThat(address.getLongitude()).isEqualTo(127.0292); + } + + @Test + @DisplayName("AddressType enum 값이 올바르게 정의되어 있다") + void addressTypeEnumHasCorrectValues() { + // given + AddressType[] addressTypes = AddressType.values(); + + // when & then + assertThat(addressTypes).hasSize(4); + assertThat(addressTypes).contains( + AddressType.HOME, + AddressType.SCHOOL, + AddressType.OFFICE, + AddressType.ETC + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetTest.java b/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetTest.java new file mode 100644 index 0000000..1f7ca56 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/Budget/BudgetTest.java @@ -0,0 +1,73 @@ +package com.stcom.smartmealtable.domain.Budget; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.MemberProfile; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import org.junit.jupiter.api.Test; + +class BudgetTest { + + @Test + void 예산_생성() throws Exception { + + // given + MemberProfile profile = getMember(); + // when + DailyBudget budget1 = new DailyBudget(profile, BigDecimal.valueOf(100000), LocalDate.now()); + MonthlyBudget budget2 = new MonthlyBudget(profile, BigDecimal.valueOf(100000), YearMonth.now()); + // then + assertThat(budget1.getLimit()).isEqualTo(BigDecimal.valueOf(100000)); + assertThat(budget1.getDate()).isNotNull(); + + assertThat(budget2.getLimit()).isEqualTo(BigDecimal.valueOf(100000)); + } + + private MemberProfile getMember() { + return new MemberProfile(); + } + + @Test + void 예산_소비_정수() throws Exception { + // given + MemberProfile profile = getMember(); + Budget budget = new DailyBudget(profile, BigDecimal.valueOf(100000), LocalDate.now()); + // when + budget.addSpent(1000); + // then + assertThat(budget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(1000)); + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(99000)); + } + + @Test + void 예산_소비_소수() throws Exception { + // given + MemberProfile profile = getMember(); + Budget budget = new DailyBudget(profile, BigDecimal.valueOf(100000), LocalDate.now()); + // when + budget.addSpent(9999.9); + // then + assertThat(budget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(9999.9)); + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(90000.1)); + } + + @Test + void 예산_초과_유무() throws Exception { + // given + MemberProfile profile1 = getMember(); + MemberProfile profile2 = getMember(); + Budget budget1 = new DailyBudget(profile1, BigDecimal.valueOf(100000), LocalDate.now()); + Budget budget2 = new DailyBudget(profile2, BigDecimal.valueOf(100000), LocalDate.now()); + + // when + budget1.addSpent(99000); + budget2.addSpent(110000); + + // then + assertThat(budget1.isOverLimit()).isFalse(); + assertThat(budget2.isOverLimit()).isTrue(); + + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/Budget/DailyBudgetTest.java b/src/test/java/com/stcom/smartmealtable/domain/Budget/DailyBudgetTest.java new file mode 100644 index 0000000..f33dcce --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/Budget/DailyBudgetTest.java @@ -0,0 +1,89 @@ +package com.stcom.smartmealtable.domain.Budget; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.MemberProfile; +import java.math.BigDecimal; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DailyBudgetTest { + + @Test + @DisplayName("DailyBudget 객체가 정상적으로 생성된다") + void createDailyBudget() { + // given + MemberProfile memberProfile = new MemberProfile(); + BigDecimal limit = BigDecimal.valueOf(10000); + LocalDate today = LocalDate.now(); + + // when + DailyBudget dailyBudget = new DailyBudget(memberProfile, limit, today); + + // then + assertThat(dailyBudget.getMemberProfile()).isEqualTo(memberProfile); + assertThat(dailyBudget.getLimit()).isEqualTo(limit); + assertThat(dailyBudget.getDate()).isEqualTo(today); + assertThat(dailyBudget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("DailyBudget에 소비 금액을 추가할 수 있다") + void addSpentAmount() { + // given + MemberProfile memberProfile = new MemberProfile(); + BigDecimal limit = BigDecimal.valueOf(10000); + LocalDate today = LocalDate.now(); + DailyBudget dailyBudget = new DailyBudget(memberProfile, limit, today); + + // when + dailyBudget.addSpent(BigDecimal.valueOf(3000)); + dailyBudget.addSpent(1000); + dailyBudget.addSpent(1500.5); + + // then + assertThat(dailyBudget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(5500.5)); + assertThat(dailyBudget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(4499.5)); + } + + @Test + @DisplayName("DailyBudget이 한도를 초과했는지 확인할 수 있다") + void checkIfOverLimit() { + // given + MemberProfile memberProfile = new MemberProfile(); + BigDecimal limit = BigDecimal.valueOf(5000); + LocalDate today = LocalDate.now(); + DailyBudget dailyBudget = new DailyBudget(memberProfile, limit, today); + + // when + dailyBudget.addSpent(3000); + boolean beforeOverLimit = dailyBudget.isOverLimit(); + + dailyBudget.addSpent(2500); + boolean afterOverLimit = dailyBudget.isOverLimit(); + + // then + assertThat(beforeOverLimit).isFalse(); + assertThat(afterOverLimit).isTrue(); + assertThat(dailyBudget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(-500)); + } + + @Test + @DisplayName("DailyBudget의 소비 금액을 초기화할 수 있다") + void resetSpentAmount() { + // given + MemberProfile memberProfile = new MemberProfile(); + BigDecimal limit = BigDecimal.valueOf(10000); + LocalDate today = LocalDate.now(); + DailyBudget dailyBudget = new DailyBudget(memberProfile, limit, today); + dailyBudget.addSpent(5000); + + // when + dailyBudget.resetSpent(); + + // then + assertThat(dailyBudget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + assertThat(dailyBudget.getAvailableAmount()).isEqualTo(limit); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudgetTest.java b/src/test/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudgetTest.java new file mode 100644 index 0000000..8bff524 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/Budget/MonthlyBudgetTest.java @@ -0,0 +1,104 @@ +package com.stcom.smartmealtable.domain.Budget; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.stcom.smartmealtable.domain.member.MemberProfile; +import java.math.BigDecimal; +import java.time.YearMonth; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MonthlyBudgetTest { + + @Test + @DisplayName("월별 예산을 생성할 수 있다") + void createMonthlyBudget() { + // given + MemberProfile memberProfile = mock(MemberProfile.class); + BigDecimal limit = BigDecimal.valueOf(300000); + YearMonth yearMonth = YearMonth.of(2025, 5); + + // when + MonthlyBudget budget = new MonthlyBudget(memberProfile, limit, yearMonth); + + // then + assertThat(budget.getMemberProfile()).isEqualTo(memberProfile); + assertThat(budget.getLimit()).isEqualTo(limit); + assertThat(budget.getYearMonth()).isEqualTo(yearMonth); + assertThat(budget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("지출 금액을 추가할 수 있다") + void addSpent() { + // given + MemberProfile memberProfile = mock(MemberProfile.class); + BigDecimal limit = BigDecimal.valueOf(300000); + YearMonth yearMonth = YearMonth.of(2025, 5); + MonthlyBudget budget = new MonthlyBudget(memberProfile, limit, yearMonth); + + // when + budget.addSpent(BigDecimal.valueOf(50000)); + budget.addSpent(10000); // int 값 추가 + budget.addSpent(5000.5); // double 값 추가 + + // then + assertThat(budget.getSpendAmount()).isEqualTo(BigDecimal.valueOf(65000.5)); + } + + @Test + @DisplayName("사용 가능한 예산 금액을 계산할 수 있다") + void getAvailableAmount() { + // given + MemberProfile memberProfile = mock(MemberProfile.class); + BigDecimal limit = BigDecimal.valueOf(300000); + YearMonth yearMonth = YearMonth.of(2025, 5); + MonthlyBudget budget = new MonthlyBudget(memberProfile, limit, yearMonth); + + // when + budget.addSpent(BigDecimal.valueOf(100000)); + + // then + assertThat(budget.getAvailableAmount()).isEqualTo(BigDecimal.valueOf(200000)); + } + + @Test + @DisplayName("예산 초과 여부를 확인할 수 있다") + void isOverLimit() { + // given + MemberProfile memberProfile = mock(MemberProfile.class); + BigDecimal limit = BigDecimal.valueOf(300000); + YearMonth yearMonth = YearMonth.of(2025, 5); + MonthlyBudget budget = new MonthlyBudget(memberProfile, limit, yearMonth); + + // when - 예산 이내 지출 + budget.addSpent(BigDecimal.valueOf(200000)); + + // then + assertThat(budget.isOverLimit()).isFalse(); + + // when - 예산 초과 지출 + budget.addSpent(BigDecimal.valueOf(150000)); + + // then + assertThat(budget.isOverLimit()).isTrue(); + } + + @Test + @DisplayName("지출 금액을 초기화할 수 있다") + void resetSpent() { + // given + MemberProfile memberProfile = mock(MemberProfile.class); + BigDecimal limit = BigDecimal.valueOf(300000); + YearMonth yearMonth = YearMonth.of(2025, 5); + MonthlyBudget budget = new MonthlyBudget(memberProfile, limit, yearMonth); + budget.addSpent(BigDecimal.valueOf(100000)); + + // when + budget.resetSpent(); + + // then + assertThat(budget.getSpendAmount()).isEqualTo(BigDecimal.ZERO); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/food/FoodCategoryTest.java b/src/test/java/com/stcom/smartmealtable/domain/food/FoodCategoryTest.java new file mode 100644 index 0000000..3e58394 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/food/FoodCategoryTest.java @@ -0,0 +1,55 @@ +package com.stcom.smartmealtable.domain.food; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class FoodCategoryTest { + + @Test + @DisplayName("FoodCategory 객체의 필드값이 올바르게 설정된다") + void foodCategoryFieldsAreSetCorrectly() { + // given + FoodCategory foodCategory = new FoodCategory(); + + // when + ReflectionTestUtils.setField(foodCategory, "id", 1L); + ReflectionTestUtils.setField(foodCategory, "name", "한식"); + + // then + assertThat(foodCategory.getId()).isEqualTo(1L); + assertThat(foodCategory.getName()).isEqualTo("한식"); + } + + @Test + @DisplayName("다양한 음식 카테고리를 생성하고 구분할 수 있다") + void createAndDistinguishMultipleFoodCategories() { + // given + FoodCategory koreanFood = new FoodCategory(); + FoodCategory chineseFood = new FoodCategory(); + FoodCategory japaneseFood = new FoodCategory(); + FoodCategory westernFood = new FoodCategory(); + + // when + ReflectionTestUtils.setField(koreanFood, "id", 1L); + ReflectionTestUtils.setField(koreanFood, "name", "한식"); + + ReflectionTestUtils.setField(chineseFood, "id", 2L); + ReflectionTestUtils.setField(chineseFood, "name", "중식"); + + ReflectionTestUtils.setField(japaneseFood, "id", 3L); + ReflectionTestUtils.setField(japaneseFood, "name", "일식"); + + ReflectionTestUtils.setField(westernFood, "id", 4L); + ReflectionTestUtils.setField(westernFood, "name", "양식"); + + // then + assertThat(koreanFood.getId()).isNotEqualTo(chineseFood.getId()); + assertThat(koreanFood.getName()).isEqualTo("한식"); + assertThat(chineseFood.getName()).isEqualTo("중식"); + assertThat(japaneseFood.getName()).isEqualTo("일식"); + assertThat(westernFood.getName()).isEqualTo("양식"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/food/MemberCategoryPreferenceTest.java b/src/test/java/com/stcom/smartmealtable/domain/food/MemberCategoryPreferenceTest.java new file mode 100644 index 0000000..b340caa --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/food/MemberCategoryPreferenceTest.java @@ -0,0 +1,111 @@ +package com.stcom.smartmealtable.domain.food; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.member.MemberProfile; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class MemberCategoryPreferenceTest { + + @Test + @DisplayName("MemberCategoryPreference 객체가 빌더를 통해 올바르게 생성된다") + void createMemberCategoryPreferenceWithBuilder() { + // given + MemberProfile memberProfile = new MemberProfile(); + FoodCategory foodCategory = new FoodCategory(); + ReflectionTestUtils.setField(foodCategory, "id", 1L); + ReflectionTestUtils.setField(foodCategory, "name", "한식"); + + // when + MemberCategoryPreference preference = MemberCategoryPreference.builder() + .memberProfile(memberProfile) + .category(foodCategory) + .type(PreferenceType.LIKE) + .priority(1) + .build(); + + // then + assertThat(preference.getMemberProfile()).isEqualTo(memberProfile); + assertThat(preference.getCategory()).isEqualTo(foodCategory); + assertThat(preference.getCategory().getName()).isEqualTo("한식"); + assertThat(preference.getType()).isEqualTo(PreferenceType.LIKE); + assertThat(preference.getPriority()).isEqualTo(1); + } + + @Test + @DisplayName("선호도와 비선호도를 표현할 수 있다") + void expressLikeAndDislikePreferences() { + // given + MemberProfile memberProfile = new MemberProfile(); + FoodCategory koreanFood = new FoodCategory(); + FoodCategory japaneseFood = new FoodCategory(); + + ReflectionTestUtils.setField(koreanFood, "name", "한식"); + ReflectionTestUtils.setField(japaneseFood, "name", "일식"); + + // when + MemberCategoryPreference likePreference = MemberCategoryPreference.builder() + .memberProfile(memberProfile) + .category(koreanFood) + .type(PreferenceType.LIKE) + .priority(1) + .build(); + + MemberCategoryPreference dislikePreference = MemberCategoryPreference.builder() + .memberProfile(memberProfile) + .category(japaneseFood) + .type(PreferenceType.DISLIKE) + .priority(1) + .build(); + + // then + assertThat(likePreference.getType()).isEqualTo(PreferenceType.LIKE); + assertThat(likePreference.getCategory().getName()).isEqualTo("한식"); + + assertThat(dislikePreference.getType()).isEqualTo(PreferenceType.DISLIKE); + assertThat(dislikePreference.getCategory().getName()).isEqualTo("일식"); + } + + @Test + @DisplayName("선호도에 우선순위를 부여할 수 있다") + void assignPriorityToPreferences() { + // given + MemberProfile memberProfile = new MemberProfile(); + FoodCategory koreanFood = new FoodCategory(); + FoodCategory chineseFood = new FoodCategory(); + FoodCategory westernFood = new FoodCategory(); + + // when + MemberCategoryPreference firstPreference = MemberCategoryPreference.builder() + .memberProfile(memberProfile) + .category(koreanFood) + .type(PreferenceType.LIKE) + .priority(1) + .build(); + + MemberCategoryPreference secondPreference = MemberCategoryPreference.builder() + .memberProfile(memberProfile) + .category(chineseFood) + .type(PreferenceType.LIKE) + .priority(2) + .build(); + + MemberCategoryPreference thirdPreference = MemberCategoryPreference.builder() + .memberProfile(memberProfile) + .category(westernFood) + .type(PreferenceType.LIKE) + .priority(3) + .build(); + + // then + assertThat(firstPreference.getPriority()).isLessThan(secondPreference.getPriority()); + assertThat(secondPreference.getPriority()).isLessThan(thirdPreference.getPriority()); + assertThat(firstPreference.getPriority()).isEqualTo(1); + assertThat(secondPreference.getPriority()).isEqualTo(2); + assertThat(thirdPreference.getPriority()).isEqualTo(3); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/group/GroupTest.java b/src/test/java/com/stcom/smartmealtable/domain/group/GroupTest.java new file mode 100644 index 0000000..30349e9 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/group/GroupTest.java @@ -0,0 +1,61 @@ +package com.stcom.smartmealtable.domain.group; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.Address.Address; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class GroupTest { + + @Test + @DisplayName("CompanyGroup의 getTypeName은 industryType의 description을 반환한다") + void companyGroupGetTypeName() { + // given + CompanyGroup companyGroup = new CompanyGroup(); + ReflectionTestUtils.setField(companyGroup, "industryType", IndustryType.IT); + + // when + String typeName = companyGroup.getTypeName(); + + // then + assertThat(typeName).isEqualTo("IT"); + } + + @Test + @DisplayName("SchoolGroup의 getTypeName은 schoolType의 name을 반환한다") + void schoolGroupGetTypeName() { + // given + SchoolGroup schoolGroup = new SchoolGroup(); + ReflectionTestUtils.setField(schoolGroup, "schoolType", SchoolType.UNIVERSITY_FOUR_YEAR); + + // when + String typeName = schoolGroup.getTypeName(); + + // then + assertThat(typeName).isEqualTo("UNIVERSITY_FOUR_YEAR"); + } + + @Test + @DisplayName("IndustryType enum의 getDescription 메소드가 올바른 값을 반환한다") + void industryTypeGetDescription() { + // given & when & then + assertThat(IndustryType.IT.getDescription()).isEqualTo("IT"); + assertThat(IndustryType.FINANCE.getDescription()).isEqualTo("파이낸스"); + assertThat(IndustryType.MANUFACTURING.getDescription()).isEqualTo("제조업"); + assertThat(IndustryType.SERVICE.getDescription()).isEqualTo("서비스"); + } + + @Test + @DisplayName("SchoolType enum의 getDescription 메소드가 올바른 값을 반환한다") + void schoolTypeGetDescription() { + // given & when & then + assertThat(SchoolType.UNIVERSITY_FOUR_YEAR.getDescription()).isEqualTo("대학교(4년제)"); + assertThat(SchoolType.UNIVERSITY_TWO_YEAR.getDescription()).isEqualTo("대학교(2년제)"); + assertThat(SchoolType.HIGH_SCHOOL.getDescription()).isEqualTo("고등학교"); + assertThat(SchoolType.MIDDLE_SCHOOL.getDescription()).isEqualTo("중학교"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/member/MemberPasswordTest.java b/src/test/java/com/stcom/smartmealtable/domain/member/MemberPasswordTest.java new file mode 100644 index 0000000..7725cb8 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/member/MemberPasswordTest.java @@ -0,0 +1,111 @@ +package com.stcom.smartmealtable.domain.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +class MemberPasswordTest { + + @Test + void 비밀번호_정책_성공() throws Exception { + String successRawPassword = "abcdefg123"; + assertDoesNotThrow(() -> + createPassword(successRawPassword) + ); + } + + private MemberPassword createPassword(String rawPassword) throws PasswordPolicyException { + return new MemberPassword(rawPassword); + } + + @Test + void 비밀번호_정책_8자이상() throws Exception { + String failedRawPassword = "abc123"; + // JUnit5 + checkFailedCase(failedRawPassword); + } + + private void checkFailedCase(String failedRawPassword) { + assertThrows(PasswordPolicyException.class, () -> + createPassword(failedRawPassword) + ); + } + + @Test + void 비밀번호_정책_공백() throws Exception { + String failedRawPassword = "aa bb124gf"; + checkFailedCase(failedRawPassword); + } + + @Test + void 비밀번호_정책_20자이하() throws Exception { + String failedRawPassword = "aasdafsf124e124241414114214"; + checkFailedCase(failedRawPassword); + } + + @Test + void 비밀번호_정책_영어숫자포함() throws Exception { + String failedRawPassword = "가나다라4asfsadfasd"; + checkFailedCase(failedRawPassword); + } + + + @Test + void 비밀번호_일치() throws Exception { + // given + MemberPassword newPassword = createPassword("abcdefg1234"); + // then + assertThat(newPassword.isMatched("abcdefg1234")).isTrue(); + } + + @Test + void 비밀번호_변경_실패_이전비밀번호_불일치() throws Exception { + MemberPassword oldPassword = createPassword("abcdefg1234"); + + assertThrows(PasswordFailedExceededException.class, + () -> oldPassword.changePassword("abcdccc1234", "abcdefg123")); // 실패해야 함.) + } + + @Test + void 비밀번호_변경_실패_새비밀번호_정책실패() throws Exception { + MemberPassword oldPassword = createPassword("abcdefg1234"); + + assertThrows(PasswordPolicyException.class, + () -> oldPassword.changePassword("abcde", "abcdefg1234")); // 실패해야 함.) + } + + @Test + void 비밀번호_변경_실패_새비밀번호_기존과동일() throws Exception { + MemberPassword oldPassword = createPassword("abcdefg1234"); + + assertThrows(PasswordPolicyException.class, + () -> oldPassword.changePassword("abcdefg1234", "abcdefg1234")); // 실패해야 함.) + } + + + @Test + void 비밀번호_변경_성공() throws Exception { + MemberPassword password = new MemberPassword("abcdefg1234"); + + assertDoesNotThrow( + () -> password.changePassword("aaabcd1234", "abcdefg1234")); // 실패해야 함.) + assertTrue(password.isMatched("aaabcd1234")); + } + + @Test + @DisplayName("비밀번호 연속 실패 5회까지는 false 반환하고, 6회 시 예외 발생해야 한다") + void 비밀번호_연속_실패_제한_초과() throws Exception { + MemberPassword password = createPassword("abcdefg1234"); + for (int i = 0; i < 5; i++) { + assertThat(password.isMatched("wrong")).isFalse(); + } + assertThrows(PasswordFailedExceededException.class, () -> password.isMatched("wrong")); + } + +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/member/MemberTest.java b/src/test/java/com/stcom/smartmealtable/domain/member/MemberTest.java new file mode 100644 index 0000000..44c24a8 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/member/MemberTest.java @@ -0,0 +1,110 @@ +package com.stcom.smartmealtable.domain.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MemberTest { + + @Test + @DisplayName("회원 생성 시 이메일, 이름, 비밀번호가 정상적으로 설정되어야 한다") + void createMember() throws PasswordPolicyException { + // given + String email = "test@example.com"; + String fullName = "홍길동"; + String password = "Password123!"; + + // when + Member member = Member.builder() + .email(email) + .fullName(fullName) + .rawPassword(password) + .build(); + + // then + assertThat(member.getEmail()).isEqualTo(email); + assertThat(member.getFullName()).isEqualTo(fullName); + assertThat(member.isEmailVerified()).isTrue(); // 기본값 true + } + + @Test + @DisplayName("유효하지 않은 형식의 비밀번호로 회원을 생성하면 예외가 발생한다") + void createMemberWithInvalidPassword() { + // given + String email = "test@example.com"; + String fullName = "홍길동"; + String weakPassword = "123456"; // 취약한 비밀번호 + + // when & then + assertThatThrownBy(() -> Member.builder() + .email(email) + .fullName(fullName) + .rawPassword(weakPassword) + .build()) + .isInstanceOf(PasswordPolicyException.class); + } + + @Test + @DisplayName("비밀번호 변경이 정상적으로 동작해야 한다") + void changePassword() throws PasswordPolicyException, PasswordFailedExceededException { + // given + Member member = Member.builder() + .email("test@example.com") + .fullName("홍길동") + .rawPassword("Password123!") + .build(); + + // when + member.changePassword("Password123!", "NewPassword123!"); + + // then + assertThat(member.isMatchedPassword("NewPassword123!")).isTrue(); + } + + @Test + @DisplayName("잘못된 비밀번호로 비밀번호 변경 시 예외가 발생해야 한다") + void changePasswordWithIncorrectPassword() throws PasswordPolicyException { + // given + Member member = Member.builder() + .email("test@example.com") + .fullName("홍길동") + .rawPassword("Password123!") + .build(); + + // when & then + assertThatThrownBy(() -> member.changePassword("WrongPassword123!", "NewPassword123!")) + .isInstanceOf(PasswordFailedExceededException.class); + } + + @Test + @DisplayName("이메일 인증 상태가 정상적으로 변경되어야 한다") + void verifyEmail() throws PasswordPolicyException { + // given + Member member = new Member("test@example.com"); + + // when + member.verifyEmail(); + + // then + assertThat(member.isEmailVerified()).isTrue(); + } + + @Test + @DisplayName("회원 비밀번호 연속 실패 5회까지는 false 반환하고, 6회 시 예외 발생해야 한다") + void 비밀번호_연속_실패_제한_초과() throws PasswordPolicyException, PasswordFailedExceededException { + Member member = Member.builder() + .email("test@example.com") + .fullName("홍길동") + .rawPassword("Password123!") + .build(); + for (int i = 0; i < 5; i++) { + assertThat(member.isMatchedPassword("WrongPassword")).isFalse(); + } + assertThatThrownBy(() -> member.isMatchedPassword("WrongPassword")) + .isInstanceOf(PasswordFailedExceededException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/social/SocialAccountTest.java b/src/test/java/com/stcom/smartmealtable/domain/social/SocialAccountTest.java new file mode 100644 index 0000000..123c596 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/social/SocialAccountTest.java @@ -0,0 +1,136 @@ +package com.stcom.smartmealtable.domain.social; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.member.Member; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SocialAccountTest { + + @Test + @DisplayName("SocialAccount 객체가 빌더를 통해 올바르게 생성된다") + void createSocialAccountWithBuilder() { + // given + Member member = mock(Member.class); + String provider = "KAKAO"; + String providerUserId = "12345"; + String tokenType = "Bearer"; + String accessToken = "access-token-value"; + String refreshToken = "refresh-token-value"; + LocalDateTime expiresAt = LocalDateTime.now().plusHours(1); + + // when + SocialAccount socialAccount = SocialAccount.builder() + .member(member) + .provider(provider) + .providerUserId(providerUserId) + .tokenType(tokenType) + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenExpiresAt(expiresAt) + .build(); + + // then + assertThat(socialAccount.getMember()).isEqualTo(member); + assertThat(socialAccount.getProvider()).isEqualTo(provider); + assertThat(socialAccount.getProviderUserId()).isEqualTo(providerUserId); + assertThat(socialAccount.getTokenType()).isEqualTo(tokenType); + assertThat(socialAccount.getAccessToken()).isEqualTo(accessToken); + assertThat(socialAccount.getRefreshToken()).isEqualTo(refreshToken); + assertThat(socialAccount.getTokenExpiresAt()).isEqualTo(expiresAt); + } + + @Test + @DisplayName("토큰 정보를 업데이트할 수 있다") + void updateTokenInformation() { + // given + Member member = mock(Member.class); + SocialAccount socialAccount = SocialAccount.builder() + .member(member) + .provider("KAKAO") + .providerUserId("12345") + .tokenType("Bearer") + .accessToken("old-access-token") + .refreshToken("old-refresh-token") + .tokenExpiresAt(LocalDateTime.now()) + .build(); + + String newAccessToken = "new-access-token"; + String newRefreshToken = "new-refresh-token"; + LocalDateTime newExpiresAt = LocalDateTime.now().plusHours(2); + + // when + socialAccount.updateToken(newAccessToken, newRefreshToken, newExpiresAt); + + // then + assertThat(socialAccount.getAccessToken()).isEqualTo(newAccessToken); + assertThat(socialAccount.getRefreshToken()).isEqualTo(newRefreshToken); + assertThat(socialAccount.getTokenExpiresAt()).isEqualTo(newExpiresAt); + } + + @Test + @DisplayName("회원 프로필 등록 여부를 확인할 수 있다") + void checkIfProfileIsRegistered() { + // given + Member memberWithProfile = mock(Member.class); + when(memberWithProfile.isProfileRegistered()).thenReturn(true); + + Member memberWithoutProfile = mock(Member.class); + when(memberWithoutProfile.isProfileRegistered()).thenReturn(false); + + SocialAccount accountWithProfile = SocialAccount.builder() + .member(memberWithProfile) + .provider("KAKAO") + .providerUserId("12345") + .build(); + + SocialAccount accountWithoutProfile = SocialAccount.builder() + .member(memberWithoutProfile) + .provider("GOOGLE") + .providerUserId("67890") + .build(); + + // when & then + assertThat(accountWithProfile.isProfileRegistered()).isTrue(); + assertThat(accountWithoutProfile.isProfileRegistered()).isFalse(); + } + + @Test + @DisplayName("다양한 소셜 제공자로 계정을 생성할 수 있다") + void createAccountsWithDifferentProviders() { + // given + Member member = mock(Member.class); + + // when + SocialAccount kakaoAccount = SocialAccount.builder() + .member(member) + .provider("KAKAO") + .providerUserId("kakao-12345") + .build(); + + SocialAccount googleAccount = SocialAccount.builder() + .member(member) + .provider("GOOGLE") + .providerUserId("google-12345") + .build(); + + SocialAccount naverAccount = SocialAccount.builder() + .member(member) + .provider("NAVER") + .providerUserId("naver-12345") + .build(); + + // then + assertThat(kakaoAccount.getProvider()).isEqualTo("KAKAO"); + assertThat(googleAccount.getProvider()).isEqualTo("GOOGLE"); + assertThat(naverAccount.getProvider()).isEqualTo("NAVER"); + + assertThat(kakaoAccount.getProviderUserId()).isEqualTo("kakao-12345"); + assertThat(googleAccount.getProviderUserId()).isEqualTo("google-12345"); + assertThat(naverAccount.getProviderUserId()).isEqualTo("naver-12345"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/domain/term/TermTest.java b/src/test/java/com/stcom/smartmealtable/domain/term/TermTest.java new file mode 100644 index 0000000..7860c30 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/domain/term/TermTest.java @@ -0,0 +1,105 @@ +package com.stcom.smartmealtable.domain.term; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class TermTest { + + @Test + @DisplayName("Term 객체의 필드값이 올바르게 설정된다") + void termFieldsAreSetCorrectly() { + // given + Term term = new Term(); + + // when + ReflectionTestUtils.setField(term, "id", 1L); + ReflectionTestUtils.setField(term, "version", 1); + ReflectionTestUtils.setField(term, "title", "이용약관"); + ReflectionTestUtils.setField(term, "content", "이용약관 내용입니다."); + ReflectionTestUtils.setField(term, "isRequired", true); + + // then + assertThat(term.getId()).isEqualTo(1L); + assertThat(term.getVersion()).isEqualTo(1); + assertThat(term.getTitle()).isEqualTo("이용약관"); + assertThat(term.getContent()).isEqualTo("이용약관 내용입니다."); + assertThat(term.getIsRequired()).isTrue(); + } + + @Test + @DisplayName("필수 약관과 선택 약관을 구분할 수 있다") + void canDistinguishBetweenRequiredAndOptionalTerms() { + // given + Term requiredTerm = new Term(); + Term optionalTerm = new Term(); + + // when + ReflectionTestUtils.setField(requiredTerm, "isRequired", true); + ReflectionTestUtils.setField(optionalTerm, "isRequired", false); + + // then + assertThat(requiredTerm.getIsRequired()).isTrue(); + assertThat(optionalTerm.getIsRequired()).isFalse(); + } +} + +class TermAgreementTest { + + @Test + @DisplayName("TermAgreement 빌더를 통해 객체가 올바르게 생성된다") + void termAgreementIsCreatedCorrectlyUsingBuilder() { + // given + Member member = new Member(); + Term term = new Term(); + + ReflectionTestUtils.setField(term, "id", 1L); + ReflectionTestUtils.setField(term, "title", "이용약관"); + + // when + TermAgreement termAgreement = TermAgreement.builder() + .member(member) + .term(term) + .isAgreed(true) + .build(); + + // then + assertThat(termAgreement.getMember()).isEqualTo(member); + assertThat(termAgreement.getTerm()).isEqualTo(term); + assertThat(termAgreement.getTerm().getId()).isEqualTo(1L); + assertThat(termAgreement.getIsAgreed()).isTrue(); + } + + @Test + @DisplayName("필수 약관 동의 여부를 확인할 수 있다") + void canCheckIfRequiredTermIsAgreed() { + // given + Member member = new Member(); + Term requiredTerm = new Term(); + + ReflectionTestUtils.setField(requiredTerm, "isRequired", true); + + // when + TermAgreement agreedTermAgreement = TermAgreement.builder() + .member(member) + .term(requiredTerm) + .isAgreed(true) + .build(); + + TermAgreement disagreedTermAgreement = TermAgreement.builder() + .member(member) + .term(requiredTerm) + .isAgreed(false) + .build(); + + // then + assertThat(agreedTermAgreement.getTerm().getIsRequired()).isTrue(); + assertThat(agreedTermAgreement.getIsAgreed()).isTrue(); + + assertThat(disagreedTermAgreement.getTerm().getIsRequired()).isTrue(); + assertThat(disagreedTermAgreement.getIsAgreed()).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java b/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java new file mode 100644 index 0000000..780c790 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/infrastructure/KakaoAddressApiServiceTest.java @@ -0,0 +1,26 @@ +package com.stcom.smartmealtable.infrastructure; + +import com.stcom.smartmealtable.infrastructure.dto.AddressRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class KakaoAddressApiServiceTest { + + @InjectMocks + private KakaoAddressApiService kakaoAddressApiService; + + @Test + @DisplayName("카카오 주소 API 서비스 테스트") + void kakaoAddressApiServiceTest() { + // given + ReflectionTestUtils.setField(kakaoAddressApiService, "clientId", "test-client-id"); + + // 실제 API 호출이 필요한 테스트는 통합 테스트에서 수행해야 합니다. + // 이 단위 테스트에서는 RestClient 모킹이 복잡하므로 생략합니다. + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/integration/MemberIntegrationTest.java b/src/test/java/com/stcom/smartmealtable/integration/MemberIntegrationTest.java new file mode 100644 index 0000000..30685a0 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/integration/MemberIntegrationTest.java @@ -0,0 +1,68 @@ +package com.stcom.smartmealtable.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.repository.MemberRepository; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class MemberIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("회원 가입 통합 테스트") + void createMember() throws Exception { + // given + String email = "test@example.com"; + String password = "Password123!"; + String fullName = "홍길동"; + + Map request = new HashMap<>(); + request.put("email", email); + request.put("password", password); + request.put("confirmPassword", password); + request.put("fullName", fullName); + + // when & then + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()) + .andExpect(jsonPath("$.data.newUser").value(true)); + + // DB 확인 + Member savedMember = memberRepository.findByEmail(email).orElse(null); + assertThat(savedMember).isNotNull(); + assertThat(savedMember.getEmail()).isEqualTo(email); + assertThat(savedMember.getFullName()).isEqualTo(fullName); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/repository/MemberRepositoryTest.java b/src/test/java/com/stcom/smartmealtable/repository/MemberRepositoryTest.java new file mode 100644 index 0000000..fb0c9d8 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/repository/MemberRepositoryTest.java @@ -0,0 +1,105 @@ +package com.stcom.smartmealtable.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class MemberRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + @DisplayName("회원을 저장하고 ID로 조회할 수 있어야 한다") + void saveAndFindById() throws PasswordPolicyException { + // given + Member member = Member.builder() + .email("test@example.com") + .fullName("홍길동") + .rawPassword("Password123!") + .build(); + + // when + Member savedMember = memberRepository.save(member); + entityManager.flush(); + entityManager.clear(); + + // then + Optional foundMember = memberRepository.findById(savedMember.getId()); + assertThat(foundMember).isPresent(); + assertThat(foundMember.get().getEmail()).isEqualTo("test@example.com"); + assertThat(foundMember.get().getFullName()).isEqualTo("홍길동"); + } + + @Test + @DisplayName("이메일로 회원을 조회할 수 있어야 한다") + void findByEmail() throws PasswordPolicyException { + // given + String email = "test@example.com"; + Member member = Member.builder() + .email(email) + .fullName("홍길동") + .rawPassword("Password123!") + .build(); + + memberRepository.save(member); + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundMember = memberRepository.findByEmail(email); + + // then + assertThat(foundMember).isPresent(); + assertThat(foundMember.get().getEmail()).isEqualTo(email); + } + + @Test + @DisplayName("존재하지 않는 이메일로 조회하면 빈 Optional을 반환해야 한다") + void findByEmailNotFound() { + // given + String nonExistentEmail = "nonexistent@example.com"; + + // when + Optional foundMember = memberRepository.findByEmail(nonExistentEmail); + + // then + assertThat(foundMember).isEmpty(); + } + + @Test + @DisplayName("회원을 삭제할 수 있어야 한다") + void deleteMember() throws PasswordPolicyException { + // given + Member member = Member.builder() + .email("test@example.com") + .fullName("홍길동") + .rawPassword("Password123!") + .build(); + + Member savedMember = memberRepository.save(member); + entityManager.flush(); + + // when + memberRepository.delete(savedMember); + entityManager.flush(); + entityManager.clear(); + + // then + Optional foundMember = memberRepository.findById(savedMember.getId()); + assertThat(foundMember).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java new file mode 100644 index 0000000..b917792 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/BudgetServiceTest.java @@ -0,0 +1,141 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.Budget.DailyBudget; +import com.stcom.smartmealtable.domain.Budget.MonthlyBudget; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.BudgetRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BudgetServiceTest { + + @InjectMocks + private BudgetService budgetService; + + @Mock + private BudgetRepository budgetRepository; + + @Mock + private MemberProfileRepository memberProfileRepository; + + @Test + @DisplayName("회원 프로필 ID로 최근 일일 예산을 조회할 수 있다") + void findRecentDailyBudgetByMemberProfileId() { + // given + Long memberProfileId = 1L; + MemberProfile memberProfile = new MemberProfile(); + DailyBudget dailyBudget = new DailyBudget(memberProfile, BigDecimal.valueOf(10000), LocalDate.now()); + + when(budgetRepository.findFirstDailyBudgetByMemberProfileId(memberProfileId)) + .thenReturn(Optional.of(dailyBudget)); + + // when + DailyBudget result = budgetService.findRecentDailyBudgetByMemberProfileId(memberProfileId); + + // then + assertThat(result).isEqualTo(dailyBudget); + assertThat(result.getLimit()).isEqualTo(BigDecimal.valueOf(10000)); + } + + @Test + @DisplayName("존재하지 않는 회원 프로필 ID로 일일 예산을 조회하면 예외가 발생한다") + void findRecentDailyBudgetByMemberProfileId_NotFound() { + // given + Long memberProfileId = 999L; + + when(budgetRepository.findFirstDailyBudgetByMemberProfileId(memberProfileId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> budgetService.findRecentDailyBudgetByMemberProfileId(memberProfileId)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } + + @Test + @DisplayName("회원 프로필 ID로 최근 월별 예산을 조회할 수 있다") + void findRecentMonthlyBudgetByMemberProfileId() { + // given + Long memberProfileId = 1L; + MemberProfile memberProfile = new MemberProfile(); + MonthlyBudget monthlyBudget = new MonthlyBudget(memberProfile, BigDecimal.valueOf(300000), YearMonth.now()); + + when(budgetRepository.findFirstMonthlyBudgetByMemberProfileId(memberProfileId)) + .thenReturn(Optional.of(monthlyBudget)); + + // when + MonthlyBudget result = budgetService.findRecentMonthlyBudgetByMemberProfileId(memberProfileId); + + // then + assertThat(result).isEqualTo(monthlyBudget); + assertThat(result.getLimit()).isEqualTo(BigDecimal.valueOf(300000)); + } + + @Test + @DisplayName("월별 예산을 저장할 수 있다") + void saveMonthlyBudgetCustom() { + // given + Long memberProfileId = 1L; + Long limit = 300000L; + MemberProfile memberProfile = new MemberProfile(); + + when(memberProfileRepository.findById(memberProfileId)) + .thenReturn(Optional.of(memberProfile)); + + // when + budgetService.saveMonthlyBudgetCustom(memberProfileId, limit); + + // then + verify(budgetRepository).save(any(MonthlyBudget.class)); + } + + @Test + @DisplayName("일일 예산을 저장할 수 있다") + void saveDailyBudgetCustom() { + // given + Long memberProfileId = 1L; + Long limit = 10000L; + MemberProfile memberProfile = new MemberProfile(); + + when(memberProfileRepository.findById(memberProfileId)) + .thenReturn(Optional.of(memberProfile)); + + // when + budgetService.saveDailyBudgetCustom(memberProfileId, limit); + + // then + verify(budgetRepository).save(any(DailyBudget.class)); + } + + @Test + @DisplayName("존재하지 않는 회원 프로필 ID로 예산을 저장하면 예외가 발생한다") + void saveBudget_NotFound() { + // given + Long memberProfileId = 999L; + Long limit = 10000L; + + when(memberProfileRepository.findById(memberProfileId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> budgetService.saveDailyBudgetCustom(memberProfileId, limit)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("존재하지 않는 프로필로 접근"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/GroupServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/GroupServiceTest.java new file mode 100644 index 0000000..8be1f60 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/GroupServiceTest.java @@ -0,0 +1,142 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.group.CompanyGroup; +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.IndustryType; +import com.stcom.smartmealtable.domain.group.SchoolGroup; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.repository.GroupRepository; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Limit; + +@ExtendWith(MockitoExtension.class) +class GroupServiceTest { + + @Mock + private GroupRepository groupRepository; + + @InjectMocks + private GroupServiceImpl groupService; + + @Test + @DisplayName("ID로 그룹을 찾을 수 있어야 한다") + void findGroupByGroupId() { + // given + Long groupId = 1L; + + CompanyGroup companyGroup = new CompanyGroup(); + Address address = createAddress("서울시 강남구 테헤란로 123"); + + // 리플렉션으로 private 필드 설정 + org.springframework.test.util.ReflectionTestUtils.setField(companyGroup, "id", groupId); + org.springframework.test.util.ReflectionTestUtils.setField(companyGroup, "name", "IT 회사"); + org.springframework.test.util.ReflectionTestUtils.setField(companyGroup, "address", address); + org.springframework.test.util.ReflectionTestUtils.setField(companyGroup, "industryType", IndustryType.IT); + + when(groupRepository.findById(groupId)).thenReturn(Optional.of(companyGroup)); + + // when + Group foundGroup = groupService.findGroupByGroupId(groupId); + + // then + assertThat(foundGroup).isEqualTo(companyGroup); + assertThat(foundGroup.getName()).isEqualTo("IT 회사"); + assertThat(foundGroup.getTypeName()).isEqualTo("IT"); + assertThat(foundGroup.getAddress().getRoadAddress()).isEqualTo("서울시 강남구 테헤란로 123"); + verify(groupRepository, times(1)).findById(groupId); + } + + @Test + @DisplayName("존재하지 않는 그룹 ID로 조회 시 예외가 발생해야 한다") + void findGroupByGroupIdNotFound() { + // given + Long groupId = 999L; + when(groupRepository.findById(groupId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> groupService.findGroupByGroupId(groupId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @Test + @DisplayName("키워드로 그룹을 검색할 수 있어야 한다") + void findGroupsByKeyword() { + // given + String keyword = "학교"; + + SchoolGroup schoolGroup1 = createSchoolGroup(1L, "서울대학교", SchoolType.UNIVERSITY_FOUR_YEAR, + createAddress("서울시 관악구 관악로 1")); + + SchoolGroup schoolGroup2 = createSchoolGroup(2L, "부산대학교", SchoolType.UNIVERSITY_FOUR_YEAR, + createAddress("부산시 금정구 부산대학로 63번길 2")); + + List expectedGroups = Arrays.asList(schoolGroup1, schoolGroup2); + + when(groupRepository.findByNameContaining(keyword, Limit.of(10))).thenReturn(expectedGroups); + + // when + List foundGroups = groupService.findGroupsByKeyword(keyword); + + // then + assertThat(foundGroups).hasSize(2); + assertThat(foundGroups).containsExactly(schoolGroup1, schoolGroup2); + + assertThat(foundGroups.get(0).getName()).isEqualTo("서울대학교"); + assertThat(foundGroups.get(0).getTypeName()).isEqualTo("UNIVERSITY_FOUR_YEAR"); + + assertThat(foundGroups.get(1).getName()).isEqualTo("부산대학교"); + assertThat(foundGroups.get(1).getAddress().getRoadAddress()).isEqualTo("부산시 금정구 부산대학로 63번길 2"); + + verify(groupRepository, times(1)).findByNameContaining(keyword, Limit.of(10)); + } + + @Test + @DisplayName("키워드 검색 결과가 없을 경우 빈 리스트를 반환해야 한다") + void findGroupsByKeywordNoResult() { + // given + String keyword = "존재하지 않는 키워드"; + when(groupRepository.findByNameContaining(keyword, Limit.of(10))).thenReturn(List.of()); + + // when + List foundGroups = groupService.findGroupsByKeyword(keyword); + + // then + assertThat(foundGroups).isEmpty(); + verify(groupRepository, times(1)).findByNameContaining(keyword, Limit.of(10)); + } + + // 테스트용 주소 생성 헬퍼 메소드 + private Address createAddress(String roadAddress) { + Address address = new Address(); + org.springframework.test.util.ReflectionTestUtils.setField(address, "roadAddress", roadAddress); + return address; + } + + // 테스트용 학교 그룹 생성 헬퍼 메소드 + private SchoolGroup createSchoolGroup(Long id, String name, SchoolType schoolType, Address address) { + SchoolGroup group = new SchoolGroup(); + org.springframework.test.util.ReflectionTestUtils.setField(group, "id", id); + org.springframework.test.util.ReflectionTestUtils.setField(group, "name", name); + org.springframework.test.util.ReflectionTestUtils.setField(group, "address", address); + org.springframework.test.util.ReflectionTestUtils.setField(group, "schoolType", schoolType); + return group; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/LoginServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/LoginServiceTest.java new file mode 100644 index 0000000..86b3858 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/LoginServiceTest.java @@ -0,0 +1,238 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import com.stcom.smartmealtable.service.dto.AuthResultDto; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class LoginServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private SocialAccountRepository socialAccountRepository; + + @InjectMocks + private LoginService loginService; + + @Captor + private ArgumentCaptor memberCaptor; + + @Captor + private ArgumentCaptor socialAccountCaptor; + + @Test + @DisplayName("이메일과 비밀번호로 로그인이 가능해야 한다") + void loginWithEmail() throws PasswordFailedExceededException, PasswordPolicyException { + // given + String email = "test@example.com"; + String password = "Password123!"; + + Member member = createMember(1L, email, password); + MemberProfile profile = mock(MemberProfile.class); + when(profile.getId()).thenReturn(10L); + + // 프로필이 등록되어 있지 않은 경우 + ReflectionTestUtils.setField(member, "memberProfile", profile); + + when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); + + // when + AuthResultDto result = loginService.loginWithEmail(email, password); + + // then + assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.getProfileId()).isEqualTo(10L); + assertThat(result.isNewUser()).isFalse(); // 프로필이 있으므로 새 사용자가 아님 + + verify(memberRepository, times(1)).findByEmail(email); + } + + @Test + @DisplayName("프로필이 없는 경우 신규 사용자로 처리해야 한다") + void loginWithEmailNewUser() throws PasswordFailedExceededException, PasswordPolicyException { + // given + String email = "new@example.com"; + String password = "Password123!"; + + Member member = createMember(2L, email, password); + // 프로필이 등록되어 있지 않음 + + when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); + + // when + AuthResultDto result = loginService.loginWithEmail(email, password); + + // then + assertThat(result.getMemberId()).isEqualTo(2L); + assertThat(result.getProfileId()).isNull(); + assertThat(result.isNewUser()).isTrue(); // 프로필이 없으므로 신규 사용자 + } + + @Test + @DisplayName("존재하지 않는 이메일로 로그인 시도 시 예외가 발생해야 한다") + void loginWithNonExistingEmail() { + // given + String email = "nonexisting@example.com"; + String password = "Password123!"; + + when(memberRepository.findByEmail(email)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> loginService.loginWithEmail(email, password)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다."); + } + + @Test + @DisplayName("잘못된 비밀번호로 로그인 시도 시 예외가 발생해야 한다") + void loginWithWrongPassword() throws PasswordPolicyException { + // given + String email = "test@example.com"; + String correctPassword = "Password123!"; + String wrongPassword = "WrongPassword123!"; + + Member member = createMember(1L, email, correctPassword); + + when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> loginService.loginWithEmail(email, wrongPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("비밀번호가 일치하지 않습니다"); + } + + @Test + @DisplayName("소셜 로그인 - 기존 회원인 경우") + void socialLoginExistingMember() throws PasswordPolicyException { + // given + String email = "test@example.com"; + String provider = "KAKAO"; + String providerUserId = "12345"; + + TokenDto tokenDto = createTokenDto(email, provider, providerUserId); + Member member = createMember(1L, email, null); + + SocialAccount existingSocialAccount = SocialAccount.builder() + .member(member) + .provider(provider) + .providerUserId(providerUserId) + .accessToken("old-token") + .refreshToken("old-refresh-token") + .tokenExpiresAt(LocalDateTime.now()) + .build(); + + when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); + when(socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)) + .thenReturn(Optional.of(existingSocialAccount)); + when(socialAccountRepository.findProfileIdByProviderAndProviderUserId(provider, providerUserId)) + .thenReturn(Optional.of(10L)); + + // when + AuthResultDto result = loginService.socialLogin(tokenDto); + + // then + assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.getProfileId()).isEqualTo(10L); + assertThat(result.isNewUser()).isFalse(); + + assertThat(existingSocialAccount.getAccessToken()).isEqualTo("access-token-value"); + assertThat(existingSocialAccount.getRefreshToken()).isEqualTo("refresh-token-value"); + } + + @Test + @DisplayName("소셜 로그인 - 신규 회원인 경우") + void socialLoginNewMember() throws PasswordPolicyException { + // given + String email = "new@example.com"; + String provider = "GOOGLE"; + String providerUserId = "67890"; + + TokenDto tokenDto = createTokenDto(email, provider, providerUserId); + Member newMember = createMember(2L, email, null); + + when(memberRepository.findByEmail(email)).thenReturn(Optional.empty()); + when(memberRepository.save(any())).thenReturn(newMember); + when(socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)) + .thenReturn(Optional.empty()); + when(socialAccountRepository.save(any())).thenAnswer(invocation -> { + SocialAccount account = invocation.getArgument(0); + ReflectionTestUtils.setField(account, "id", 1L); + return account; + }); + when(socialAccountRepository.findProfileIdByProviderAndProviderUserId(provider, providerUserId)) + .thenReturn(Optional.empty()); + + // when + AuthResultDto result = loginService.socialLogin(tokenDto); + + // then + verify(memberRepository, times(1)).save(memberCaptor.capture()); + verify(socialAccountRepository, times(1)).save(socialAccountCaptor.capture()); + + Member savedMember = memberCaptor.getValue(); + SocialAccount savedAccount = socialAccountCaptor.getValue(); + + assertThat(savedMember.getEmail()).isEqualTo(email); + assertThat(savedAccount.getProvider()).isEqualTo(provider); + assertThat(savedAccount.getProviderUserId()).isEqualTo(providerUserId); + + assertThat(result.getMemberId()).isEqualTo(2L); + assertThat(result.getProfileId()).isNull(); + assertThat(result.isNewUser()).isTrue(); + } + + private Member createMember(Long id, String email, String rawPassword) throws PasswordPolicyException { + Member member; + if (rawPassword != null) { + member = Member.builder() + .email(email) + .rawPassword(rawPassword) + .build(); + } else { + member = new Member(email); + } + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + private TokenDto createTokenDto(String email, String provider, String providerUserId) { + TokenDto tokenDto = new TokenDto(); + + tokenDto.setEmail(email); + tokenDto.setProvider(provider); + tokenDto.setProviderUserId(providerUserId); + tokenDto.setTokenType("Bearer"); + tokenDto.setAccessToken("access-token-value"); + tokenDto.setRefreshToken("refresh-token-value"); + tokenDto.setExpiresIn(3600); + + return tokenDto; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceTest.java new file mode 100644 index 0000000..10f852a --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberCategoryPreferenceServiceTest.java @@ -0,0 +1,186 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.food.FoodCategory; +import com.stcom.smartmealtable.domain.food.MemberCategoryPreference; +import com.stcom.smartmealtable.domain.food.PreferenceType; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.repository.FoodCategoryRepository; +import com.stcom.smartmealtable.repository.MemberCategoryPreferenceRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class MemberCategoryPreferenceServiceTest { + + @Mock + private MemberCategoryPreferenceRepository preferenceRepository; + + @Mock + private FoodCategoryRepository categoryRepository; + + @Mock + private MemberProfileRepository profileRepository; + + @InjectMocks + private MemberCategoryPreferenceService preferenceService; + + @Captor + private ArgumentCaptor preferenceCaptor; + + private MemberProfile profile; + private FoodCategory koreanFood; + private FoodCategory westernFood; + private FoodCategory japaneseFood; + private FoodCategory chineseFood; + + @BeforeEach + void setUp() { + // 테스트 데이터 셋업 + profile = new MemberProfile(); + ReflectionTestUtils.setField(profile, "id", 1L); + + koreanFood = createFoodCategory(1L, "한식"); + westernFood = createFoodCategory(2L, "양식"); + japaneseFood = createFoodCategory(3L, "일식"); + chineseFood = createFoodCategory(4L, "중식"); + } + + @Test + @DisplayName("회원 음식 선호도를 저장할 수 있어야 한다") + void savePreferences() { + // given + Long profileId = 1L; + List liked = Arrays.asList(1L, 2L); // 한식, 양식 선호 + List disliked = Arrays.asList(3L); // 일식 비선호 + + when(profileRepository.findById(profileId)).thenReturn(Optional.of(profile)); + when(categoryRepository.findById(1L)).thenReturn(Optional.of(koreanFood)); + when(categoryRepository.findById(2L)).thenReturn(Optional.of(westernFood)); + when(categoryRepository.findById(3L)).thenReturn(Optional.of(japaneseFood)); + doNothing().when(preferenceRepository).deleteByMemberProfile_Id(profileId); + when(preferenceRepository.save(any(MemberCategoryPreference.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + preferenceService.savePreferences(profileId, liked, disliked); + + // then + verify(profileRepository, times(1)).findById(profileId); + verify(preferenceRepository, times(1)).deleteByMemberProfile_Id(profileId); + verify(categoryRepository, times(3)).findById(anyLong()); + verify(preferenceRepository, times(3)).save(preferenceCaptor.capture()); + + List savedPreferences = preferenceCaptor.getAllValues(); + assertThat(savedPreferences).hasSize(3); + + // 선호 음식 검증 + assertThat(savedPreferences.get(0).getType()).isEqualTo(PreferenceType.LIKE); + assertThat(savedPreferences.get(0).getCategory().getName()).isEqualTo("한식"); + assertThat(savedPreferences.get(0).getPriority()).isEqualTo(1); + + assertThat(savedPreferences.get(1).getType()).isEqualTo(PreferenceType.LIKE); + assertThat(savedPreferences.get(1).getCategory().getName()).isEqualTo("양식"); + assertThat(savedPreferences.get(1).getPriority()).isEqualTo(2); + + // 비선호 음식 검증 + assertThat(savedPreferences.get(2).getType()).isEqualTo(PreferenceType.DISLIKE); + assertThat(savedPreferences.get(2).getCategory().getName()).isEqualTo("일식"); + assertThat(savedPreferences.get(2).getPriority()).isEqualTo(1); + } + + @Test + @DisplayName("존재하지 않는 프로필로 음식 선호도 저장시 예외가 발생해야 한다") + void savePreferencesWithNonExistingProfile() { + // given + Long profileId = 999L; + List liked = Arrays.asList(1L); + List disliked = List.of(); + + when(profileRepository.findById(profileId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> preferenceService.savePreferences(profileId, liked, disliked)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 카테고리로 음식 선호도 저장시 예외가 발생해야 한다") + void savePreferencesWithNonExistingCategory() { + // given + Long profileId = 1L; + List liked = Arrays.asList(999L); + List disliked = List.of(); + + when(profileRepository.findById(profileId)).thenReturn(Optional.of(profile)); + when(categoryRepository.findById(999L)).thenReturn(Optional.empty()); + doNothing().when(preferenceRepository).deleteByMemberProfile_Id(profileId); + + // when & then + assertThatThrownBy(() -> preferenceService.savePreferences(profileId, liked, disliked)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("존재하지 않는 카테고리입니다"); + } + + @Test + @DisplayName("프로필 ID로 음식 선호도를 조회할 수 있어야 한다") + void getPreferences() { + // given + Long profileId = 1L; + MemberCategoryPreference pref1 = createPreference(profile, koreanFood, PreferenceType.LIKE, 1); + MemberCategoryPreference pref2 = createPreference(profile, westernFood, PreferenceType.LIKE, 2); + MemberCategoryPreference pref3 = createPreference(profile, japaneseFood, PreferenceType.DISLIKE, 1); + + List expectedPreferences = Arrays.asList(pref1, pref2, pref3); + + when(preferenceRepository.findDefaultByMemberProfileId(profileId)).thenReturn(expectedPreferences); + + // when + List foundPreferences = preferenceService.getPreferences(profileId); + + // then + assertThat(foundPreferences).hasSize(3); + assertThat(foundPreferences).isEqualTo(expectedPreferences); + + verify(preferenceRepository, times(1)).findDefaultByMemberProfileId(profileId); + } + + private FoodCategory createFoodCategory(Long id, String name) { + FoodCategory foodCategory = new FoodCategory(); + ReflectionTestUtils.setField(foodCategory, "id", id); + ReflectionTestUtils.setField(foodCategory, "name", name); + return foodCategory; + } + + private MemberCategoryPreference createPreference(MemberProfile profile, FoodCategory category, + PreferenceType type, Integer priority) { + MemberCategoryPreference preference = MemberCategoryPreference.builder() + .memberProfile(profile) + .category(category) + .type(type) + .priority(priority) + .build(); + ReflectionTestUtils.setField(preference, "id", Long.valueOf(priority)); + return preference; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java new file mode 100644 index 0000000..70995c3 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberProfileServiceTest.java @@ -0,0 +1,394 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.Address.AddressEntity; +import com.stcom.smartmealtable.domain.Address.AddressType; +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.SchoolGroup; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.member.MemberProfile; +import com.stcom.smartmealtable.domain.member.MemberType; +import com.stcom.smartmealtable.repository.AddressEntityRepository; +import com.stcom.smartmealtable.repository.GroupRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class MemberProfileServiceTest { + + @Mock + private MemberProfileRepository memberProfileRepository; + + @Mock + private GroupRepository groupRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private AddressEntityRepository addressEntityRepository; + + @InjectMocks + private MemberProfileService profileService; + + @Captor + private ArgumentCaptor profileCaptor; + + @Captor + private ArgumentCaptor addressEntityCaptor; + + private Member member; + private Group group; + private Address address; + private MemberProfile profile; + + @BeforeEach + void setUp() { + // 테스트 데이터 셋업 + member = new Member("test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + group = new SchoolGroup(); + ReflectionTestUtils.setField(group, "id", 1L); + ReflectionTestUtils.setField(group, "name", "서울대학교"); + ReflectionTestUtils.setField(group, "schoolType", SchoolType.UNIVERSITY_FOUR_YEAR); + + address = createAddress("서울특별시 관악구 관악로 1", "서울특별시 관악구", "101호", 37.459, 126.952); + } + + @Test + @DisplayName("프로필 ID로 프로필 정보를 조회할 수 있어야 한다") + void getProfileFetch() { + // given + Long profileId = 1L; + profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); + + when(memberProfileRepository.findMemberProfileEntityGraphById(profileId)) + .thenReturn(Optional.of(profile)); + + // when + MemberProfile fetchedProfile = profileService.getProfileFetch(profileId); + + // then + assertThat(fetchedProfile).isEqualTo(profile); + assertThat(fetchedProfile.getNickName()).isEqualTo("닉네임"); + assertThat(fetchedProfile.getMember()).isEqualTo(member); + assertThat(fetchedProfile.getType()).isEqualTo(MemberType.STUDENT); + assertThat(fetchedProfile.getGroup()).isEqualTo(group); + + verify(memberProfileRepository).findMemberProfileEntityGraphById(profileId); + } + + @Test + @DisplayName("프로필을 생성할 수 있어야 한다 - 그룹 있음") + void createProfileWithGroup() { + // given + String nickName = "새닉네임"; + Long memberId = 1L; + MemberType type = MemberType.STUDENT; + Long groupId = 1L; + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + when(groupRepository.getReferenceById(groupId)).thenReturn(group); + when(memberProfileRepository.save(any(MemberProfile.class))).thenAnswer(invocation -> { + MemberProfile savedProfile = invocation.getArgument(0); + ReflectionTestUtils.setField(savedProfile, "id", 1L); + return savedProfile; + }); + + // when + profileService.createProfile(nickName, memberId, type, groupId); + + // then + verify(memberRepository).findById(memberId); + verify(groupRepository).getReferenceById(groupId); + verify(memberProfileRepository).save(profileCaptor.capture()); + + MemberProfile savedProfile = profileCaptor.getValue(); + assertThat(savedProfile.getNickName()).isEqualTo(nickName); + assertThat(savedProfile.getMember()).isEqualTo(member); + assertThat(savedProfile.getType()).isEqualTo(type); + assertThat(savedProfile.getGroup()).isEqualTo(group); + } + + @Test + @DisplayName("프로필을 생성할 수 있어야 한다 - 그룹 없음") + void createProfileWithoutGroup() { + // given + String nickName = "새닉네임"; + Long memberId = 1L; + MemberType type = MemberType.OTHER; + Long groupId = null; + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + when(memberProfileRepository.save(any(MemberProfile.class))).thenAnswer(invocation -> { + MemberProfile savedProfile = invocation.getArgument(0); + ReflectionTestUtils.setField(savedProfile, "id", 1L); + return savedProfile; + }); + + // when + profileService.createProfile(nickName, memberId, type, groupId); + + // then + verify(memberRepository).findById(memberId); + verify(memberProfileRepository).save(profileCaptor.capture()); + + MemberProfile savedProfile = profileCaptor.getValue(); + assertThat(savedProfile.getNickName()).isEqualTo(nickName); + assertThat(savedProfile.getMember()).isEqualTo(member); + assertThat(savedProfile.getType()).isEqualTo(type); + assertThat(savedProfile.getGroup()).isNull(); + } + + @Test + @DisplayName("프로필 정보를 변경할 수 있어야 한다") + void changeProfile() { + // given + Long profileId = 1L; + String newNickName = "변경된닉네임"; + MemberType newType = MemberType.WORKER; + Long newGroupId = 2L; + + profile = createProfile(profileId, "원래닉네임", member, MemberType.STUDENT, group); + + Group newGroup = new SchoolGroup(); + ReflectionTestUtils.setField(newGroup, "id", 2L); + ReflectionTestUtils.setField(newGroup, "name", "회사"); + + when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); + when(groupRepository.getReferenceById(newGroupId)).thenReturn(newGroup); + + // when + profileService.changeProfile(profileId, newNickName, newType, newGroupId); + + // then + verify(memberProfileRepository).findById(profileId); + verify(groupRepository).getReferenceById(newGroupId); + + assertThat(profile.getNickName()).isEqualTo(newNickName); + assertThat(profile.getType()).isEqualTo(newType); + assertThat(profile.getGroup()).isEqualTo(newGroup); + } + + @Test + @DisplayName("새 주소를 추가할 수 있어야 한다") + void saveNewAddress() { + // given + Long profileId = 1L; + String alias = "집"; + AddressType addressType = AddressType.HOME; + + profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); + ReflectionTestUtils.setField(profile, "addressHistory", new ArrayList<>()); + + when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); + when(addressEntityRepository.save(any(AddressEntity.class))).thenAnswer(invocation -> { + AddressEntity savedAddress = invocation.getArgument(0); + ReflectionTestUtils.setField(savedAddress, "id", 1L); + return savedAddress; + }); + + // when + profileService.saveNewAddress(profileId, address, alias, addressType); + + // then + verify(memberProfileRepository).findById(profileId); + verify(addressEntityRepository).save(addressEntityCaptor.capture()); + + AddressEntity savedAddressEntity = addressEntityCaptor.getValue(); + assertThat(savedAddressEntity.getAddress()).isEqualTo(address); + assertThat(savedAddressEntity.getAlias()).isEqualTo(alias); + assertThat(savedAddressEntity.getType()).isEqualTo(addressType); + assertThat(profile.getAddressHistory()).hasSize(1); + } + + @Test + @DisplayName("주소 정보를 변경할 수 있어야 한다") + void changeAddress() { + // given + Long profileId = 1L; + Long addressEntityId = 1L; + String newAlias = "새집"; + AddressType newType = AddressType.HOME; + + profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); + + AddressEntity addressEntity = AddressEntity.builder() + .address(address) + .alias("구집") + .type(AddressType.ETC) + .build(); + ReflectionTestUtils.setField(addressEntity, "id", addressEntityId); + + List addresses = new ArrayList<>(); + addresses.add(addressEntity); + ReflectionTestUtils.setField(profile, "addressHistory", addresses); + + Address newAddress = createAddress("서울시 서초구 서초대로 123", "서초구", "202호", 37.5, 127.0); + + when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); + when(addressEntityRepository.findById(addressEntityId)).thenReturn(Optional.of(addressEntity)); + + // when + profileService.changeAddress(profileId, addressEntityId, newAddress, newAlias, newType); + + // then + verify(memberProfileRepository).findById(profileId); + verify(addressEntityRepository).findById(addressEntityId); + + // 주소 정보가 업데이트되었는지 확인 + assertThat(addressEntity.getAddress()).isEqualTo(newAddress); + assertThat(addressEntity.getAlias()).isEqualTo(newAlias); + assertThat(addressEntity.getType()).isEqualTo(newType); + } + + @Test + @DisplayName("주소를 삭제할 수 있어야 한다") + void deleteAddress() { + // given + Long profileId = 1L; + Long addressEntityId = 1L; + + profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); + + AddressEntity addressEntity = AddressEntity.builder() + .address(address) + .alias("집") + .type(AddressType.HOME) + .build(); + ReflectionTestUtils.setField(addressEntity, "id", addressEntityId); + + // 주소에 primary=true 설정 + ReflectionTestUtils.setField(addressEntity, "primary", true); + + List addresses = new ArrayList<>(); + addresses.add(addressEntity); + + // 두번째 주소 추가 + AddressEntity secondAddress = AddressEntity.builder() + .address(createAddress("서울시 강남구", "강남구", "301호", 37.4, 127.1)) + .alias("회사") + .type(AddressType.OFFICE) + .build(); + ReflectionTestUtils.setField(secondAddress, "id", 2L); + addresses.add(secondAddress); + + ReflectionTestUtils.setField(profile, "addressHistory", addresses); + + when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); + when(addressEntityRepository.findById(addressEntityId)).thenReturn(Optional.of(addressEntity)); + + // when + profileService.deleteAddress(profileId, addressEntityId); + + // then + verify(memberProfileRepository).findById(profileId); + verify(addressEntityRepository).findById(addressEntityId); + + // 주소가 삭제되었는지 확인 + assertThat(profile.getAddressHistory()).hasSize(1); + assertThat(profile.getAddressHistory().get(0)).isEqualTo(secondAddress); + } + + @Test + @DisplayName("기본 예산을 설정할 수 있어야 한다") + void registerDefaultBudgets() { + // given + Long profileId = 1L; + Long dailyLimit = 10000L; + Long monthlyLimit = 300000L; + + profile = createProfile(profileId, "닉네임", member, MemberType.STUDENT, group); + + when(memberProfileRepository.findById(profileId)).thenReturn(Optional.of(profile)); + + // when + profileService.registerDefaultBudgets(profileId, dailyLimit, monthlyLimit); + + // then + verify(memberProfileRepository).findById(profileId); + + // 기본 예산 설정 로직은 실제로는 MemberProfile 내부 메서드에 있으므로 + // 이 테스트에서는 메서드 호출 여부만 확인 + } + + @Test + @DisplayName("존재하지 않는 프로필 ID로 조회 시 예외가 발생해야 한다") + void getProfileFetchNotFound() { + // given + Long nonExistingProfileId = 999L; + when(memberProfileRepository.findMemberProfileEntityGraphById(nonExistingProfileId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> profileService.getProfileFetch(nonExistingProfileId)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("존재하지 않는 프로필입니다"); + } + + @Test + @DisplayName("존재하지 않는 회원 ID로 프로필 생성 시 예외가 발생해야 한다") + void createProfileWithNonExistingMember() { + // given + Long nonExistingMemberId = 999L; + when(memberRepository.findById(nonExistingMemberId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> profileService.createProfile("닉네임", nonExistingMemberId, MemberType.STUDENT, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + private MemberProfile createProfile(Long id, String nickName, Member member, MemberType type, Group group) { + MemberProfile profile = MemberProfile.builder() + .nickName(nickName) + .member(member) + .type(type) + .group(group) + .build(); + ReflectionTestUtils.setField(profile, "id", id); + return profile; + } + + private Address createAddress(String roadAddress, String detailAddress, String alias, + double latitude, double longitude) { + Address address = Address.builder() + .roadAddress(roadAddress) + .detailAddress(detailAddress) + .latitude(latitude) + .longitude(longitude) + .build(); + return address; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/MemberServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/MemberServiceTest.java new file mode 100644 index 0000000..ece2673 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/MemberServiceTest.java @@ -0,0 +1,157 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.exception.PasswordFailedExceededException; +import com.stcom.smartmealtable.exception.PasswordPolicyException; +import com.stcom.smartmealtable.repository.AddressEntityRepository; +import com.stcom.smartmealtable.repository.MemberProfileRepository; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private MemberProfileRepository memberProfileRepository; + + @Mock + private SocialAccountRepository socialAccountRepository; + + @Mock + private AddressEntityRepository addressEntityRepository; + + @InjectMocks + private MemberService memberService; + + @Test + @DisplayName("회원 정보를 정상적으로 저장할 수 있어야 한다") + void saveMember() throws PasswordPolicyException { + // given + Member member = Member.builder() + .email("test@example.com") + .fullName("홍길동") + .rawPassword("Password123!") + .build(); + + when(memberRepository.save(any(Member.class))).thenReturn(member); + + // when + memberService.saveMember(member); + + // then + verify(memberRepository, times(1)).save(any(Member.class)); + } + + @Test + @DisplayName("ID로 회원 조회 시 회원이 존재하면 회원 정보를 반환해야 한다") + void findMemberByMemberId() throws PasswordPolicyException { + // given + Long memberId = 1L; + Member member = Member.builder() + .email("test@example.com") + .fullName("홍길동") + .rawPassword("Password123!") + .build(); + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + + // when + Member foundMember = memberService.findMemberByMemberId(memberId); + + // then + assertThat(foundMember).isEqualTo(member); + verify(memberRepository, times(1)).findById(memberId); + } + + @Test + @DisplayName("ID로 회원 조회 시 회원이 존재하지 않으면 예외가 발생해야 한다") + void findMemberByMemberIdNotFound() { + // given + Long memberId = 999L; + when(memberRepository.findById(memberId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberService.findMemberByMemberId(memberId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @Test + @DisplayName("비밀번호 확인이 일치하지 않으면 예외가 발생해야 한다") + void checkPasswordDoublyFail() { + // given + String password = "Password123!"; + String confirmPassword = "DifferentPassword123!"; + + // when & then + assertThatThrownBy(() -> memberService.checkPasswordDoubly(password, confirmPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("비밀번호가 일치하지 않습니다"); + } + + @Test + @DisplayName("비밀번호 변경이 정상적으로 동작해야 한다") + void changePassword() throws PasswordPolicyException, PasswordFailedExceededException { + // given + Long memberId = 1L; + String originPassword = "OriginPassword123!"; + String newPassword = "NewPassword123!"; + + Member member = Member.builder() + .email("test@example.com") + .fullName("홍길동") + .rawPassword(originPassword) + .build(); + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + + // when + memberService.changePassword(memberId, originPassword, newPassword); + + // then + assertThat(member.isMatchedPassword(newPassword)).isTrue(); + } + + @Test + @DisplayName("회원 삭제가 정상적으로 동작해야 한다") + void deleteByMemberId() throws PasswordPolicyException { + // given + Long memberId = 1L; + Member member = Member.builder() + .email("test@example.com") + .fullName("홍길동") + .rawPassword("Password123!") + .build(); + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + doNothing().when(memberProfileRepository).deleteMemberProfileByMember(any(Member.class)); + doNothing().when(socialAccountRepository).deleteSocialAccountByMember(any(Member.class)); + doNothing().when(memberRepository).delete(any(Member.class)); + + // when + memberService.deleteByMemberId(memberId); + + // then + verify(memberProfileRepository, times(1)).deleteMemberProfileByMember(member); + verify(socialAccountRepository, times(1)).deleteSocialAccountByMember(member); + verify(memberRepository, times(1)).delete(member); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceTest.java new file mode 100644 index 0000000..c8bb193 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/SocialAccountServiceTest.java @@ -0,0 +1,302 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.social.SocialAccount; +import com.stcom.smartmealtable.infrastructure.dto.TokenDto; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.SocialAccountRepository; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class SocialAccountServiceTest { + + @Mock + private SocialAccountRepository socialAccountRepository; + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private SocialAccountService socialAccountService; + + @Captor + private ArgumentCaptor memberCaptor; + + @Captor + private ArgumentCaptor socialAccountCaptor; + + @Test + @DisplayName("새 회원 생성 및 소셜 계정 연결이 가능해야 한다") + void createNewMemberAndLinkSocialAccount() { + // given + TokenDto tokenDto = createTokenDto("test@example.com", "KAKAO", "12345"); + + when(memberRepository.save(any(Member.class))).thenAnswer(invocation -> { + Member member = invocation.getArgument(0); + ReflectionTestUtils.setField(member, "id", 1L); + return member; + }); + + when(socialAccountRepository.save(any(SocialAccount.class))).thenAnswer(invocation -> { + SocialAccount account = invocation.getArgument(0); + ReflectionTestUtils.setField(account, "id", 1L); + return account; + }); + + // when + socialAccountService.createNewMemberAndLinkSocialAccount(tokenDto); + + // then + verify(memberRepository, times(1)).save(memberCaptor.capture()); + verify(socialAccountRepository, times(1)).save(socialAccountCaptor.capture()); + + Member savedMember = memberCaptor.getValue(); + SocialAccount savedAccount = socialAccountCaptor.getValue(); + + assertThat(savedMember.getEmail()).isEqualTo("test@example.com"); + assertThat(savedAccount.getMember()).isEqualTo(savedMember); + assertThat(savedAccount.getProvider()).isEqualTo("KAKAO"); + assertThat(savedAccount.getProviderUserId()).isEqualTo("12345"); + assertThat(savedAccount.getAccessToken()).isEqualTo("access-token-value"); + assertThat(savedAccount.getRefreshToken()).isEqualTo("refresh-token-value"); + } + + @Test + @DisplayName("기존 회원에 소셜 계정 연결이 가능해야 한다") + void linkSocialAccount() { + // given + String email = "test@example.com"; + TokenDto tokenDto = createTokenDto(email, "GOOGLE", "67890"); + + Member existingMember = new Member(email); + ReflectionTestUtils.setField(existingMember, "id", 1L); + + when(memberRepository.findByEmail(email)).thenReturn(Optional.of(existingMember)); + when(socialAccountRepository.save(any(SocialAccount.class))).thenAnswer(invocation -> { + SocialAccount account = invocation.getArgument(0); + ReflectionTestUtils.setField(account, "id", 2L); + return account; + }); + + // when + socialAccountService.linkSocialAccount(tokenDto); + + // then + verify(memberRepository, times(1)).findByEmail(email); + verify(socialAccountRepository, times(1)).save(socialAccountCaptor.capture()); + + SocialAccount savedAccount = socialAccountCaptor.getValue(); + + assertThat(savedAccount.getMember()).isEqualTo(existingMember); + assertThat(savedAccount.getProvider()).isEqualTo("GOOGLE"); + assertThat(savedAccount.getProviderUserId()).isEqualTo("67890"); + assertThat(savedAccount.getAccessToken()).isEqualTo("access-token-value"); + } + + @Test + @DisplayName("존재하지 않는 이메일로 소셜 계정 연결 시 예외가 발생해야 한다") + void linkSocialAccountWithNonExistingEmail() { + // given + String email = "nonexisting@example.com"; + TokenDto tokenDto = createTokenDto(email, "KAKAO", "12345"); + + when(memberRepository.findByEmail(email)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> socialAccountService.linkSocialAccount(tokenDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("회원이 null일 수는 없습니다"); + } + + @Test + @DisplayName("Provider와 ProviderUserId로 소셜 계정을 찾을 수 있어야 한다") + void findSocialAccount() { + // given + String provider = "KAKAO"; + String providerUserId = "12345"; + + Member member = new Member("test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + SocialAccount socialAccount = SocialAccount.builder() + .member(member) + .provider(provider) + .providerUserId(providerUserId) + .build(); + ReflectionTestUtils.setField(socialAccount, "id", 1L); + + when(socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)) + .thenReturn(Optional.of(socialAccount)); + + // when + SocialAccount foundAccount = socialAccountService.findSocialAccount(provider, providerUserId); + + // then + assertThat(foundAccount).isNotNull(); + assertThat(foundAccount.getProvider()).isEqualTo(provider); + assertThat(foundAccount.getProviderUserId()).isEqualTo(providerUserId); + assertThat(foundAccount.getMember()).isEqualTo(member); + + verify(socialAccountRepository, times(1)).findByProviderAndProviderUserId(provider, providerUserId); + } + + @Test + @DisplayName("존재하지 않는 소셜 계정 찾기 시 null을 반환해야 한다") + void findNonExistingSocialAccount() { + // given + String provider = "KAKAO"; + String providerUserId = "nonexisting"; + + when(socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)) + .thenReturn(Optional.empty()); + + // when + SocialAccount foundAccount = socialAccountService.findSocialAccount(provider, providerUserId); + + // then + assertThat(foundAccount).isNull(); + } + + @Test + @DisplayName("신규 사용자 여부를 확인할 수 있어야 한다") + void isNewUser() { + // given + String existingProvider = "KAKAO"; + String existingProviderId = "12345"; + + String newProvider = "GOOGLE"; + String newProviderId = "67890"; + + when(socialAccountRepository.findByProviderAndProviderUserId(existingProvider, existingProviderId)) + .thenReturn(Optional.of(new SocialAccount())); + + when(socialAccountRepository.findByProviderAndProviderUserId(newProvider, newProviderId)) + .thenReturn(Optional.empty()); + + // when + boolean existingUserResult = socialAccountService.isNewUser(existingProvider, existingProviderId); + boolean newUserResult = socialAccountService.isNewUser(newProvider, newProviderId); + + // then + assertThat(existingUserResult).isFalse(); + assertThat(newUserResult).isTrue(); + } + + @Test + @DisplayName("토큰 정보를 업데이트할 수 있어야 한다") + void updateToken() { + // given + Long socialAccountId = 1L; + + Member member = new Member("test@example.com"); + SocialAccount socialAccount = SocialAccount.builder() + .member(member) + .provider("KAKAO") + .providerUserId("12345") + .tokenType("Bearer") + .accessToken("old-access-token") + .refreshToken("old-refresh-token") + .tokenExpiresAt(LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(socialAccount, "id", socialAccountId); + + String newAccessToken = "new-access-token"; + String newRefreshToken = "new-refresh-token"; + LocalDateTime newExpiresAt = LocalDateTime.now().plusHours(1); + + when(socialAccountRepository.findById(socialAccountId)).thenReturn(Optional.of(socialAccount)); + + // when + socialAccountService.updateToken(socialAccountId, newAccessToken, newRefreshToken, newExpiresAt); + + // then + verify(socialAccountRepository, times(1)).findById(socialAccountId); + + assertThat(socialAccount.getAccessToken()).isEqualTo(newAccessToken); + assertThat(socialAccount.getRefreshToken()).isEqualTo(newRefreshToken); + assertThat(socialAccount.getTokenExpiresAt()).isEqualTo(newExpiresAt); + } + + @Test + @DisplayName("존재하지 않는 소셜 계정 ID로 토큰 업데이트 시 예외가 발생해야 한다") + void updateTokenWithNonExistingId() { + // given + Long nonExistingId = 999L; + + when(socialAccountRepository.findById(nonExistingId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> socialAccountService.updateToken( + nonExistingId, "new-token", "new-refresh-token", LocalDateTime.now())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("확인되지 않은 계정입니다"); + } + + @Test + @DisplayName("회원 ID로 연결된 모든 소셜 Provider 목록을 가져올 수 있어야 한다") + void findAllProviders() { + // given + Long memberId = 1L; + + Member member = new Member("test@example.com"); + + SocialAccount kakaoAccount = SocialAccount.builder() + .member(member) + .provider("KAKAO") + .providerUserId("kakao-12345") + .build(); + + SocialAccount googleAccount = SocialAccount.builder() + .member(member) + .provider("GOOGLE") + .providerUserId("google-67890") + .build(); + + List socialAccounts = Arrays.asList(kakaoAccount, googleAccount); + + when(socialAccountRepository.findAllByMemberId(memberId)).thenReturn(socialAccounts); + + // when + List providers = socialAccountService.findAllProviders(memberId); + + // then + assertThat(providers).hasSize(2); + assertThat(providers).containsExactly("KAKAO", "GOOGLE"); + + verify(socialAccountRepository, times(1)).findAllByMemberId(memberId); + } + + private TokenDto createTokenDto(String email, String provider, String providerUserId) { + TokenDto tokenDto = new TokenDto(); + + tokenDto.setEmail(email); + tokenDto.setProvider(provider); + tokenDto.setProviderUserId(providerUserId); + tokenDto.setTokenType("Bearer"); + tokenDto.setAccessToken("access-token-value"); + tokenDto.setRefreshToken("refresh-token-value"); + tokenDto.setExpiresIn(3600); + + return tokenDto; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/service/TermServiceTest.java b/src/test/java/com/stcom/smartmealtable/service/TermServiceTest.java new file mode 100644 index 0000000..b28eee9 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/service/TermServiceTest.java @@ -0,0 +1,173 @@ +package com.stcom.smartmealtable.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.domain.term.Term; +import com.stcom.smartmealtable.domain.term.TermAgreement; +import com.stcom.smartmealtable.repository.MemberRepository; +import com.stcom.smartmealtable.repository.TermAgreementRepository; +import com.stcom.smartmealtable.repository.TermRepository; +import com.stcom.smartmealtable.service.dto.TermAgreementRequestDto; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TermServiceTest { + + @InjectMocks + private TermService termService; + + @Mock + private TermRepository termRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private TermAgreementRepository termAgreementRepository; + + @Test + @DisplayName("모든 약관을 조회할 수 있다") + void findAll() { + // given + Term term1 = createTerm(1L, "이용약관", true); + Term term2 = createTerm(2L, "개인정보 처리방침", true); + Term term3 = createTerm(3L, "마케팅 정보 수신 동의", false); + + when(termRepository.findAll()).thenReturn(Arrays.asList(term1, term2, term3)); + + // when + List terms = termService.findAll(); + + // then + assertThat(terms).hasSize(3); + assertThat(terms.get(0).getTitle()).isEqualTo("이용약관"); + assertThat(terms.get(1).getTitle()).isEqualTo("개인정보 처리방침"); + assertThat(terms.get(2).getTitle()).isEqualTo("마케팅 정보 수신 동의"); + } + + @Test + @DisplayName("회원이 약관에 동의할 수 있다") + void agreeTerms() { + // given + Long memberId = 1L; + Member member = createMember(memberId); + + Term term1 = createTerm(1L, "이용약관", true); + Term term2 = createTerm(2L, "개인정보 처리방침", true); + Term term3 = createTerm(3L, "마케팅 정보 수신 동의", false); + + TermAgreementRequestDto dto1 = new TermAgreementRequestDto(1L, true); + TermAgreementRequestDto dto2 = new TermAgreementRequestDto(2L, true); + TermAgreementRequestDto dto3 = new TermAgreementRequestDto(3L, false); + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + when(termRepository.findAll()).thenReturn(Arrays.asList(term1, term2, term3)); + when(termRepository.findById(1L)).thenReturn(Optional.of(term1)); + when(termRepository.findById(2L)).thenReturn(Optional.of(term2)); + when(termRepository.findById(3L)).thenReturn(Optional.of(term3)); + + // when + termService.agreeTerms(memberId, Arrays.asList(dto1, dto2, dto3)); + + // then + verify(termAgreementRepository, times(3)).save(any(TermAgreement.class)); + } + + @Test + @DisplayName("필수 약관에 동의하지 않으면 예외가 발생한다") + void agreeTerms_RequiredTermNotAgreed() { + // given + Long memberId = 1L; + Member member = createMember(memberId); + + Term term1 = createTerm(1L, "이용약관", true); + Term term2 = createTerm(2L, "개인정보 처리방침", true); + + TermAgreementRequestDto dto1 = new TermAgreementRequestDto(1L, true); + TermAgreementRequestDto dto2 = new TermAgreementRequestDto(2L, false); // 필수 약관이지만 동의하지 않음 + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + when(termRepository.findAll()).thenReturn(Arrays.asList(term1, term2)); + + // when & then + assertThatThrownBy(() -> termService.agreeTerms(memberId, Arrays.asList(dto1, dto2))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("필수 약관에 동의해야 합니다"); + } + + @Test + @DisplayName("존재하지 않는 회원 ID로 약관 동의를 시도하면 예외가 발생한다") + void agreeTerms_MemberNotFound() { + // given + Long nonExistentMemberId = 999L; + TermAgreementRequestDto dto = new TermAgreementRequestDto(1L, true); + + when(memberRepository.findById(nonExistentMemberId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> termService.agreeTerms(nonExistentMemberId, List.of(dto))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다"); + } + + @Test + @DisplayName("존재하지 않는 약관 ID로 약관 동의를 시도하면 예외가 발생한다") + void agreeTerms_TermNotFound() { + // given + Long memberId = 1L; + Long nonExistentTermId = 999L; + Member member = createMember(memberId); + TermAgreementRequestDto dto = new TermAgreementRequestDto(nonExistentTermId, true); + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + when(termRepository.findAll()).thenReturn(List.of()); + when(termRepository.findById(nonExistentTermId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> termService.agreeTerms(memberId, List.of(dto))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("존재하지 않는 약관입니다"); + } + + // 테스트용 회원 생성 헬퍼 메소드 + private Member createMember(Long id) { + Member member = new Member(); + return member; + } + + // 테스트용 약관 생성 헬퍼 메소드 + private Term createTerm(Long id, String title, Boolean isRequired) { + Term term = new Term(); + // 리플렉션을 통해 private 필드에 값 설정 + try { + java.lang.reflect.Field idField = Term.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(term, id); + + java.lang.reflect.Field titleField = Term.class.getDeclaredField("title"); + titleField.setAccessible(true); + titleField.set(term, title); + + java.lang.reflect.Field isRequiredField = Term.class.getDeclaredField("isRequired"); + isRequiredField.setAccessible(true); + isRequiredField.set(term, isRequired); + } catch (Exception e) { + throw new RuntimeException(e); + } + return term; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/argumentresolver/UserContextArgumentResolverTest.java b/src/test/java/com/stcom/smartmealtable/web/argumentresolver/UserContextArgumentResolverTest.java new file mode 100644 index 0000000..4278906 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/argumentresolver/UserContextArgumentResolverTest.java @@ -0,0 +1,152 @@ +package com.stcom.smartmealtable.web.argumentresolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; + +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.web.context.request.NativeWebRequest; + +@ExtendWith(MockitoExtension.class) +class UserContextArgumentResolverTest { + + @Mock + private JwtTokenService jwtTokenService; + + @Mock + private NativeWebRequest webRequest; + + @Mock + private HttpServletRequest httpServletRequest; + + @Mock + private MethodParameter methodParameter; + + @InjectMocks + private UserContextArgumentResolver resolver; + + @BeforeEach + void setUp() { + lenient().when(webRequest.getNativeRequest(HttpServletRequest.class)).thenReturn(httpServletRequest); + } + + @Test + @DisplayName("UserContext 어노테이션과 MemberDto 타입의 파라미터를 지원해야 한다") + void supportsParameter() { + // given + when(methodParameter.hasParameterAnnotation(UserContext.class)).thenReturn(true); + when(methodParameter.getParameterType()).thenReturn((Class) MemberDto.class); + + // when + boolean result = resolver.supportsParameter(methodParameter); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("UserContext 어노테이션이 없으면 파라미터를 지원하지 않아야 한다") + void doesNotSupportParameterWithoutAnnotation() { + // given + when(methodParameter.hasParameterAnnotation(UserContext.class)).thenReturn(false); + + // when + boolean result = resolver.supportsParameter(methodParameter); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("MemberDto 타입이 아니면 파라미터를 지원하지 않아야 한다") + void doesNotSupportParameterWithWrongType() { + // given + when(methodParameter.hasParameterAnnotation(UserContext.class)).thenReturn(true); + when(methodParameter.getParameterType()).thenReturn((Class) String.class); + + // when + boolean result = resolver.supportsParameter(methodParameter); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("유효한 토큰으로부터 MemberDto를 추출해야 한다") + void resolveArgumentWithValidToken() throws Exception { + // given + String token = "Bearer valid-token"; + when(httpServletRequest.getHeader("Authorization")).thenReturn(token); + + Claims claims = mock(Claims.class); + when(claims.get("memberId", String.class)).thenReturn("1"); + when(claims.get("profileId", String.class)).thenReturn("2"); + when(claims.get("email", String.class)).thenReturn("test@example.com"); + when(claims.containsKey("profileId")).thenReturn(true); + when(claims.containsKey("email")).thenReturn(true); + + when(jwtTokenService.extractClaims(anyString())).thenReturn(claims); + + // when + Object result = resolver.resolveArgument(methodParameter, null, webRequest, null); + + // then + assertThat(result).isInstanceOf(MemberDto.class); + MemberDto memberDto = (MemberDto) result; + assertThat(memberDto.getMemberId()).isEqualTo(1L); + assertThat(memberDto.getProfileId()).isEqualTo(2L); + assertThat(memberDto.getEmail()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("토큰이 없으면 예외가 발생해야 한다") + void resolveArgumentWithNoToken() { + // given + when(httpServletRequest.getHeader("Authorization")).thenReturn(null); + + // when & then + assertThatThrownBy(() -> resolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(RuntimeException.class) + .hasMessage("권한 없음."); + } + + @Test + @DisplayName("프로필 ID가 없는 토큰으로부터 MemberDto를 추출할 수 있어야 한다") + void resolveArgumentWithTokenWithoutProfileId() throws Exception { + // given + String token = "Bearer valid-token"; + when(httpServletRequest.getHeader("Authorization")).thenReturn(token); + + Claims claims = mock(Claims.class); + when(claims.get("memberId", String.class)).thenReturn("1"); + when(claims.get("email", String.class)).thenReturn("test@example.com"); + when(claims.containsKey("profileId")).thenReturn(false); + when(claims.containsKey("email")).thenReturn(true); + + when(jwtTokenService.extractClaims(anyString())).thenReturn(claims); + + // when + Object result = resolver.resolveArgument(methodParameter, null, webRequest, null); + + // then + assertThat(result).isInstanceOf(MemberDto.class); + MemberDto memberDto = (MemberDto) result; + assertThat(memberDto.getMemberId()).isEqualTo(1L); + assertThat(memberDto.getProfileId()).isNull(); + assertThat(memberDto.getEmail()).isEqualTo("test@example.com"); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/GroupControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/GroupControllerTest.java new file mode 100644 index 0000000..a5e777f --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/GroupControllerTest.java @@ -0,0 +1,169 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.domain.Address.Address; +import com.stcom.smartmealtable.domain.group.CompanyGroup; +import com.stcom.smartmealtable.domain.group.Group; +import com.stcom.smartmealtable.domain.group.IndustryType; +import com.stcom.smartmealtable.domain.group.SchoolGroup; +import com.stcom.smartmealtable.domain.group.SchoolType; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.GroupService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; + +import io.jsonwebtoken.Claims; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +@Transactional +@TestPropertySource(properties = { + "spring.main.allow-bean-definition-overriding=true" +}) +class GroupControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private GroupService groupService; + + @MockBean + private JwtTokenService jwtTokenService; + + @Autowired + private WebApplicationContext context; + + @BeforeEach + void setup() { + // 테스트용 MockMvc 설정 + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .addFilter(new CharacterEncodingFilter("UTF-8", true)) + .build(); + + // UserContext 어노테이션 처리를 위한 설정 + // Claims 모킹 설정 + Claims claims = Mockito.mock(Claims.class); + when(claims.get("memberId", String.class)).thenReturn("1"); + when(jwtTokenService.extractClaims(Mockito.anyString())).thenReturn(claims); + } + + @Test + @DisplayName("키워드로 그룹을 검색할 수 있다") + void searchGroup() throws Exception { + // given + String keyword = "테스트"; + + // 테스트용 그룹 생성 + CompanyGroup companyGroup = createCompanyGroup("테스트 회사", IndustryType.IT, + createAddress("서울시 강남구 테헤란로 123")); + + SchoolGroup schoolGroup = createSchoolGroup("테스트 학교", SchoolType.UNIVERSITY_FOUR_YEAR, + createAddress("서울시 서초구 방배로 456")); + + when(groupService.findGroupsByKeyword(keyword)) + .thenReturn(List.of(companyGroup, schoolGroup)); + + // when & then + mockMvc.perform(get("/api/v1/groups") + .param("keyword", keyword) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].name").value("테스트 회사")) + .andExpect(jsonPath("$.data[0].groupType").value("IT")) + .andExpect(jsonPath("$.data[0].roadAddress").value("서울시 강남구 테헤란로 123")) + .andExpect(jsonPath("$.data[1].name").value("테스트 학교")) + .andExpect(jsonPath("$.data[1].groupType").value("UNIVERSITY_FOUR_YEAR")) + .andExpect(jsonPath("$.data[1].roadAddress").value("서울시 서초구 방배로 456")); + } + + @Test + @DisplayName("빈 키워드로 그룹 검색시 에러가 발생한다") + void searchGroup_EmptyKeyword() throws Exception { + // given + String keyword = ""; + + // when & then + mockMvc.perform(get("/api/v1/groups") + .param("keyword", keyword) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ERROR")) + .andExpect(jsonPath("$.message").value("키워드가 비어있습니다. 키워드를 입력해주세요")); + } + + @Test + @DisplayName("키워드로 그룹 검색시 결과가 없으면 빈 리스트를 반환한다") + void searchGroup_NoResults() throws Exception { + // given + String keyword = "존재하지 않는 키워드"; + + when(groupService.findGroupsByKeyword(keyword)) + .thenReturn(List.of()); + + // when & then + mockMvc.perform(get("/api/v1/groups") + .param("keyword", keyword) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + + // 테스트용 주소 생성 헬퍼 메소드 + private Address createAddress(String roadAddress) { + Address address = mock(Address.class); + when(address.getRoadAddress()).thenReturn(roadAddress); + return address; + } + + // 테스트용 회사 그룹 생성 헬퍼 메소드 + private CompanyGroup createCompanyGroup(String name, IndustryType industryType, Address address) { + CompanyGroup group = mock(CompanyGroup.class); + when(group.getName()).thenReturn(name); + when(group.getTypeName()).thenReturn(industryType.getDescription()); + when(group.getAddress()).thenReturn(address); + return group; + } + + // 테스트용 학교 그룹 생성 헬퍼 메소드 + private SchoolGroup createSchoolGroup(String name, SchoolType schoolType, Address address) { + SchoolGroup group = mock(SchoolGroup.class); + when(group.getName()).thenReturn(name); + when(group.getTypeName()).thenReturn(schoolType.name()); + when(group.getAddress()).thenReturn(address); + return group; + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/MemberControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/MemberControllerTest.java new file mode 100644 index 0000000..d6f2365 --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/MemberControllerTest.java @@ -0,0 +1,212 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.domain.member.Member; +import com.stcom.smartmealtable.infrastructure.dto.JwtTokenResponseDto; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.MemberService; +import com.stcom.smartmealtable.service.TermService; +import com.stcom.smartmealtable.service.dto.MemberDto; +import com.stcom.smartmealtable.web.argumentresolver.UserContext; +import com.stcom.smartmealtable.web.argumentresolver.UserContextArgumentResolver; +import com.stcom.smartmealtable.web.controller.MemberController.CreateMemberRequest; +import com.stcom.smartmealtable.web.controller.MemberController.EditMemberRequest; +import com.stcom.smartmealtable.web.controller.MemberController.TermAgreementDto; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; + +import io.jsonwebtoken.Claims; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +@Transactional +@TestPropertySource(properties = { + "spring.main.allow-bean-definition-overriding=true" +}) +class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private MemberService memberService; + + @MockBean + private JwtTokenService jwtTokenService; + + @MockBean + private TermService termService; + + @Autowired + private WebApplicationContext context; + + @BeforeEach + void setup() { + // 테스트용 MockMvc 설정 + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .addFilter(new CharacterEncodingFilter("UTF-8", true)) + .build(); + + // UserContext 어노테이션 처리를 위한 설정 + // Claims 모킹 설정 + Claims claims = Mockito.mock(Claims.class); + when(claims.get("memberId", String.class)).thenReturn("1"); + when(jwtTokenService.extractClaims(anyString())).thenReturn(claims); + } + + @Test + @DisplayName("이메일 중복 확인 API가 정상적으로 동작해야 한다") + void checkEmail() throws Exception { + // given + String email = "test@example.com"; + doNothing().when(memberService).validateDuplicatedEmail(email); + + // when & then + mockMvc.perform(get("/api/v1/members/email/check") + .param("email", email)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("회원 생성 API가 정상적으로 동작해야 한다") + void createMember() throws Exception { + // given + CreateMemberRequest request = new CreateMemberRequest( + "test@example.com", "Password123!", "Password123!", "홍길동"); + + // Member 모킹 + Member mockMember = mock(Member.class); + when(mockMember.getId()).thenReturn(1L); + + // Member Builder 모킹 + Member.MemberBuilder mockBuilder = mock(Member.MemberBuilder.class); + when(mockBuilder.fullName(anyString())).thenReturn(mockBuilder); + when(mockBuilder.email(anyString())).thenReturn(mockBuilder); + when(mockBuilder.rawPassword(anyString())).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(mockMember); + + // Member 정적 메소드 모킹 + try (var mockStatic = Mockito.mockStatic(Member.class)) { + mockStatic.when(Member::builder).thenReturn(mockBuilder); + + JwtTokenResponseDto tokenDto = new JwtTokenResponseDto( + "test-access-token", + "test-refresh-token", + 3600, + "Bearer" + ); + tokenDto.setNewUser(true); + + // MemberService 모킹 + doNothing().when(memberService).validateDuplicatedEmail(anyString()); + doNothing().when(memberService).checkPasswordDoubly(anyString(), anyString()); + doNothing().when(memberService).saveMember(any(Member.class)); + + // JwtTokenService 모킹 + when(jwtTokenService.createTokenDto(anyLong(), any())).thenReturn(tokenDto); + + // when & then + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.accessToken").value("test-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("test-refresh-token")) + .andExpect(jsonPath("$.data.newUser").value(true)); + } + } + + @Test + @DisplayName("회원 정보 수정 API가 정상적으로 동작해야 한다") + void editMember() throws Exception { + // given + EditMemberRequest request = new EditMemberRequest( + "OldPassword123!", "NewPassword123!", "NewPassword123!"); + + doNothing().when(memberService).checkPasswordDoubly(anyString(), anyString()); + doNothing().when(memberService).changePassword(anyLong(), anyString(), anyString()); + + // when & then + mockMvc.perform(patch("/api/v1/members/me") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header("Authorization", "Bearer test-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("회원 탈퇴 API가 정상적으로 동작해야 한다") + void deleteMember() throws Exception { + // given + doNothing().when(memberService).deleteByMemberId(anyLong()); + + // when & then + mockMvc.perform(delete("/api/v1/members/me") + .header("Authorization", "Bearer test-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } + + @Test + @DisplayName("약관 동의 API가 정상적으로 동작해야 한다") + void signUpWithTermAgreement() throws Exception { + // given + List agreements = Arrays.asList( + new TermAgreementDto(1L, true), + new TermAgreementDto(2L, false) + ); + + doNothing().when(termService).agreeTerms(anyLong(), any()); + + // when & then + mockMvc.perform(post("/api/v1/members/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(agreements)) + .header("Authorization", "Bearer test-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")); + } +} \ No newline at end of file diff --git a/src/test/java/com/stcom/smartmealtable/web/controller/TermControllerTest.java b/src/test/java/com/stcom/smartmealtable/web/controller/TermControllerTest.java new file mode 100644 index 0000000..687a58a --- /dev/null +++ b/src/test/java/com/stcom/smartmealtable/web/controller/TermControllerTest.java @@ -0,0 +1,131 @@ +package com.stcom.smartmealtable.web.controller; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stcom.smartmealtable.domain.term.Term; +import com.stcom.smartmealtable.security.JwtTokenService; +import com.stcom.smartmealtable.service.TermService; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; + +import io.jsonwebtoken.Claims; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +@Transactional +@TestPropertySource(properties = { + "spring.main.allow-bean-definition-overriding=true" +}) +class TermControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private TermService termService; + + @MockBean + private JwtTokenService jwtTokenService; + + @Autowired + private WebApplicationContext context; + + @BeforeEach + void setup() { + // 테스트용 MockMvc 설정 + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .addFilter(new CharacterEncodingFilter("UTF-8", true)) + .build(); + + // UserContext 어노테이션 처리를 위한 설정 + // Claims 모킹 설정 + Claims claims = Mockito.mock(Claims.class); + when(claims.get("memberId", String.class)).thenReturn("1"); + when(jwtTokenService.extractClaims(Mockito.anyString())).thenReturn(claims); + } + + @Test + @DisplayName("모든 약관을 조회할 수 있다") + void getTerms() throws Exception { + // given + Term term1 = createTerm(1L, "이용약관", "이용약관 내용입니다.", true); + Term term2 = createTerm(2L, "개인정보 처리방침", "개인정보 처리방침 내용입니다.", true); + Term term3 = createTerm(3L, "마케팅 정보 수신 동의", "마케팅 정보 수신 동의 내용입니다.", false); + + List terms = Arrays.asList(term1, term2, term3); + + when(termService.findAll()).thenReturn(terms); + + // when & then + mockMvc.perform(get("/api/v1/terms") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(3)) + .andExpect(jsonPath("$.data[0].termId").value(1)) + .andExpect(jsonPath("$.data[0].title").value("이용약관")) + .andExpect(jsonPath("$.data[0].content").value("이용약관 내용입니다.")) + .andExpect(jsonPath("$.data[0].required").value(true)) + .andExpect(jsonPath("$.data[1].termId").value(2)) + .andExpect(jsonPath("$.data[1].title").value("개인정보 처리방침")) + .andExpect(jsonPath("$.data[1].content").value("개인정보 처리방침 내용입니다.")) + .andExpect(jsonPath("$.data[1].required").value(true)) + .andExpect(jsonPath("$.data[2].termId").value(3)) + .andExpect(jsonPath("$.data[2].title").value("마케팅 정보 수신 동의")) + .andExpect(jsonPath("$.data[2].content").value("마케팅 정보 수신 동의 내용입니다.")) + .andExpect(jsonPath("$.data[2].required").value(false)); + } + + @Test + @DisplayName("약관이 없는 경우 빈 배열을 반환한다") + void getTerms_Empty() throws Exception { + // given + when(termService.findAll()).thenReturn(List.of()); + + // when & then + mockMvc.perform(get("/api/v1/terms") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + + // 테스트용 약관 생성 헬퍼 메소드 + private Term createTerm(Long id, String title, String content, Boolean isRequired) { + Term term = mock(Term.class); + when(term.getId()).thenReturn(id); + when(term.getTitle()).thenReturn(title); + when(term.getContent()).thenReturn(content); + when(term.getIsRequired()).thenReturn(isRequired); + return term; + } +} \ No newline at end of file diff --git a/table.ddl b/table.ddl new file mode 100644 index 0000000..37c9b1b --- /dev/null +++ b/table.ddl @@ -0,0 +1,217 @@ +-- 1. 회원 관련 테이블 + +-- 1.1. 회원 인증 테이블 (Member) +CREATE TABLE IF NOT EXISTS member ( + member_id BIGINT NOT NULL AUTO_INCREMENT, + email VARCHAR(255) DEFAULT NULL, + password_hash VARCHAR(255) DEFAULT NULL, + is_email_verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (member_id), + UNIQUE KEY uq_email (email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 1.2. 회원 프로필 테이블 (MemberProfile) +CREATE TABLE IF NOT EXISTS member_profile ( + profile_id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + full_name VARCHAR(255) NOT NULL, + default_image VARCHAR(255) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (profile_id), + CONSTRAINT fk_memberprofile_member FOREIGN KEY (member_id) + REFERENCES member(member_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 1.3. 소셜 로그인 테이블 (SocialLogin) +CREATE TABLE IF NOT EXISTS social_login ( + social_id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + provider VARCHAR(50) NOT NULL, + provider_user_id VARCHAR(255) NOT NULL, + access_token VARCHAR(512) NOT NULL, + refresh_token VARCHAR(512) DEFAULT NULL, + token_expires_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (social_id), + CONSTRAINT fk_sociallogin_member FOREIGN KEY (member_id) + REFERENCES member(member_id) + ON DELETE CASCADE ON UPDATE CASCADE, + UNIQUE KEY uq_provider_user (provider, provider_user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 1.4. 회원 주소 테이블 (MemberAddress) +CREATE TABLE IF NOT EXISTS member_address ( + address_id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + address VARCHAR(255) NOT NULL, -- 기본 주소 + road_address VARCHAR(255) NOT NULL, -- 도로명 주소 + detail_address VARCHAR(255), -- 상세 주소 + alias VARCHAR(255), -- 주소 별칭 + latitude DECIMAL(10,7) DEFAULT NULL, -- 위도 + longitude DECIMAL(10,7) DEFAULT NULL, -- 경도 + status VARCHAR(255) NOT NULL, -- 주소 상태 (HOME, COMPANY, ETC, N:삭제) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (address_id), + CONSTRAINT fk_memberaddress_member FOREIGN KEY (member_id) + REFERENCES member(member_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + +-- 2. 예산 관리 테이블 + +-- 2.1. 월별 예산 테이블 (MonthlyBudget) +CREATE TABLE IF NOT EXISTS monthly_budget ( + budget_id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + year_month CHAR(7) NOT NULL, -- "YYYY-MM" 형식 (예: "2025-04") + monthly_limit DECIMAL(10,2) NOT NULL, -- 한 달 목표 예산 (예: 500000.00) + spent_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- 지금까지 소비한 금액 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (budget_id), + UNIQUE KEY uq_member_yearmonth (member_id, year_month), + CONSTRAINT fk_monthlybudget_member FOREIGN KEY (member_id) + REFERENCES member(member_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2.2. 일별 예산 테이블 (DailyBudget) +CREATE TABLE IF NOT EXISTS daily_budget ( + daily_budget_id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + budget_date DATE NOT NULL, + daily_limit DECIMAL(10,2) NOT NULL, -- 일일 목표 예산 (예: 20000.00) + spent_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- 하루 동안 소비한 총액 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (daily_budget_id), + UNIQUE KEY uq_member_date (member_id, budget_date), + CONSTRAINT fk_dailybudget_member FOREIGN KEY (member_id) + REFERENCES member(member_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + +-- 3. 음식점 및 음식 정보 테이블 + +-- 3.1. 음식점 테이블 (FoodStore) +CREATE TABLE IF NOT EXISTS food_store ( + food_store_id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, -- 음식점 이름 + store_type ENUM('RESTAURANT', 'SCHOOL_CAFETERIA', 'CONVENIENCE_STORE') NOT NULL, + address VARCHAR(255) NOT NULL, -- 음식점 주소 + latitude DECIMAL(10,7) DEFAULT NULL, -- 위도 + longitude DECIMAL(10,7) DEFAULT NULL, -- 경도 + phone VARCHAR(50) DEFAULT NULL, -- 음식점 전화번호 + open_time TIME DEFAULT NULL, -- 오픈 시간 + close_time TIME DEFAULT NULL, -- 마감 시간 + external_id VARCHAR(255) DEFAULT NULL, -- 외부 음식점 ID(KAKAO) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + status VARCHAR(1) NOT NULL, -- 음식점 상태 (Y:활성화, N:삭제) + PRIMARY KEY (food_store_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3.2. 음식 (메뉴) 테이블 (Food) +CREATE TABLE IF NOT EXISTS food ( + food_id BIGINT NOT NULL AUTO_INCREMENT, + food_store_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + category VARCHAR(100) NOT NULL, -- 음식 종류 + price DECIMAL(10,2) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + status VARCHAR(1) NOT NULL, -- 음식 상태 (Y:활성화, N:삭제) + PRIMARY KEY (food_id), + CONSTRAINT fk_food_foodstore FOREIGN KEY (food_store_id) + REFERENCES food_store(food_store_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + +-- 4. 즐겨찾기 테이블 + +-- 4.1. 즐겨찾는 음식점 테이블 (FavoriteFoodStore) +CREATE TABLE IF NOT EXISTS favorite_food_store ( + favorite_id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + food_store_id BIGINT NOT NULL, + rank_order INT DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (favorite_id), + CONSTRAINT fk_favorite_member FOREIGN KEY (member_id) + REFERENCES member(member_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_favorite_foodstore FOREIGN KEY (food_store_id) + REFERENCES food_store(food_store_id) + ON DELETE CASCADE ON UPDATE CASCADE, + UNIQUE KEY uq_favorite (member_id, food_store_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + +-- 5. 지출 내역 테이블 + +-- 5.1. 지출 내역 테이블 (Expenditure) +CREATE TABLE IF NOT EXISTS expenditure ( + expenditure_id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + food_store_id BIGINT DEFAULT NULL, + transaction_date DATETIME NOT NULL, + amount DECIMAL(10,2) NOT NULL, + source_type ENUM('MANUAL', 'SMS', 'CAPTURE', 'RECOMMENDATION', 'IN_APP') NOT NULL, + description TEXT DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (expenditure_id), + CONSTRAINT fk_expenditure_member FOREIGN KEY (member_id) + REFERENCES member(member_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_expenditure_foodstore FOREIGN KEY (food_store_id) + REFERENCES food_store(food_store_id) + ON DELETE SET NULL ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 5.2. 주문서 테이블 (ExpenditureFood) +CREATE TABLE IF NOT EXISTS expenditure_food ( + expenditure_food_id BIGINT NOT NULL AUTO_INCREMENT, + expenditure_id BIGINT NOT NULL, + food_id BIGINT NOT NULL, + quantity INT NOT NULL DEFAULT 1, -- 주문한 개수 + unit_price DECIMAL(10,2) NOT NULL, -- 주문 당시 단위 가격 + total_price DECIMAL(10,2) NOT NULL, -- quantity * unit_price 계산 값 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (expenditure_food_id), + CONSTRAINT fk_expenditurefood_expenditure FOREIGN KEY (expenditure_id) + REFERENCES expenditure(expenditure_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_expenditurefood_food FOREIGN KEY (food_id) + REFERENCES food(food_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + +-- 6. 사용자 음식 취향 테이블 + +-- 6.1. 사용자 음식 취향 선호 테이블 (MemberFoodPreference) +CREATE TABLE IF NOT EXISTS member_food_preference ( + preference_id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + food_keyword VARCHAR(100) NOT NULL, + food_category VARCHAR(100) NOT NULL, + preference_order INT NOT NULL, + is_dislike BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (preference_id), + CONSTRAINT fk_preference_member FOREIGN KEY (member_id) + REFERENCES member(member_id) + ON DELETE CASCADE ON UPDATE CASCADE, + UNIQUE KEY uq_member_food (member_id, food_keyword) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;