diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index dd90c10..18c7b10 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -26,9 +26,6 @@ jobs: - name: Locate and Prepare JAR artifact id: get_jar_path run: | - # build.gradle에서 jar { enabled = false } 설정 후, - # 기본적으로 생성되는 실행 가능한 JAR 파일명을 정확히 찾음 (예: artifactId-version.jar) - # JoyCrew의 경우 'backend-0.0.1-SNAPSHOT.jar' 형태일 가능성이 높음. JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) if [ -z "$JAR_FILE" ]; then @@ -62,7 +59,9 @@ jobs: script: | set -eux - + + export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" + sudo systemctl stop joycrew-backend || true DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" diff --git a/build.gradle b/build.gradle index d23461e..7a0323a 100644 --- a/build.gradle +++ b/build.gradle @@ -23,25 +23,62 @@ repositories { mavenCentral() } +sourceSets { + main { + java { + srcDirs = ['src/main/java'] + } + resources { + srcDirs = ['src/main/resources'] + } + } + test { + java { + srcDirs = ['src/test/java'] + } + resources { + srcDirs = ['src/test/resources'] + } + } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.security:spring-security-crypto' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.security:spring-security-test' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'com.h2database:h2' + annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } +jar { + enabled = false +} + tasks.named('test') { useJUnitPlatform() -} -jar { - enabled = false -} + filter { + includeTestsMatching "com.joycrew.backend.service.*Test" + includeTestsMatching "com.joycrew.backend.repository.*Test" + includeTestsMatching "com.joycrew.backend.controller.*Test" + } + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + showStandardStreams = true + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java new file mode 100644 index 0000000..cacc67e --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -0,0 +1,111 @@ +package com.joycrew.backend.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.security.JwtAuthenticationFilter; +import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.EmployeeDetailsService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.userdetails.UserDetailsService; + +import java.util.Map; + + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final EmployeeRepository employeeRepository; + private final ObjectMapper objectMapper; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(Customizer.withDefaults()) + .csrf(csrf -> csrf.disable()) + .headers(headers -> headers + .frameOptions(frameOptions -> frameOptions.disable())) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/h2-console/**", + "/api/auth/login", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + ).permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json;charset=UTF-8"); + + // JSON 형식으로 에러 메시지 생성 + String jsonResponse = objectMapper.writeValueAsString( + Map.of("message", "로그인이 필요합니다.") + ); + + response.getWriter().write(jsonResponse); + }) + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService()), + UsernamePasswordAuthenticationFilter.class) + .formLogin(form -> form.disable()) + .httpBasic(httpBasic -> httpBasic.disable()); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOriginPattern("http://localhost:3000"); + config.addAllowedOriginPattern("https://joycrew.co.kr"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } + + @Bean + public UserDetailsService userDetailsService() { + return new EmployeeDetailsService(employeeRepository); + } + + @Bean + public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { + DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); + authenticationProvider.setUserDetailsService(userDetailsService); + authenticationProvider.setPasswordEncoder(passwordEncoder); + return new ProviderManager(authenticationProvider); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/config/SwaggerConfig.java b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java new file mode 100644 index 0000000..49150b0 --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java @@ -0,0 +1,31 @@ +package com.joycrew.backend.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.Components; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + final String securitySchemeName = "Authorization"; + + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) + .info(new Info() + .title("JoyCrew API") + .version("v1.0.0") + .description("JoyCrew 백엔드 API 명세서입니다.")); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java new file mode 100644 index 0000000..6350d98 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -0,0 +1,70 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Tag(name = "인증", description = "로그인 관련 API") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "로그인", description = "이메일과 비밀번호를 이용해 JWT 토큰과 사용자 정보를 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = LoginResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패 (이메일 또는 비밀번호 오류)", + content = @Content(mediaType = "application/json", + schema = @Schema(example = "{\"accessToken\": \"\", \"message\": \"이메일 또는 비밀번호가 올바르지 않습니다.\"}"))) + }) + @PostMapping("/login") + public ResponseEntity login(@RequestBody @Valid LoginRequest request) { + try { + return ResponseEntity.ok(authService.login(request)); + } catch (UsernameNotFoundException | BadCredentialsException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(LoginResponse.builder() + .accessToken("") + .message(e.getMessage()) + .build()); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(LoginResponse.builder() + .accessToken("") + .message("서버 오류가 발생했습니다.") + .build()); + } + } + + @Operation(summary = "로그아웃", description = "사용자의 로그아웃 요청을 처리합니다. 클라이언트는 이 응답을 받은 후 토큰을 삭제해야 합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(example = "{\"message\": \"로그아웃 되었습니다.\"}"))) + }) + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletRequest request) { + authService.logout(request); + return ResponseEntity.ok(Map.of("message", "로그아웃 되었습니다.")); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java new file mode 100644 index 0000000..c87e3ec --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -0,0 +1,74 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.UserProfileResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Optional; + +@Tag(name = "사용자", description = "사용자 정보 관련 API") +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor +public class UserController { + + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; + + @Operation(summary = "사용자 프로필 조회", description = "JWT 토큰으로 인증된 사용자의 프로필 정보를 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "토큰 없음 또는 유효하지 않음", + content = @Content(mediaType = "application/json", + schema = @Schema(example = "{\"message\": \"유효하지 않은 토큰입니다.\"}"))) + }) + @GetMapping("/profile") + public ResponseEntity getProfile(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "유효하지 않은 토큰입니다.")); + } + + String userEmail = authentication.getName(); + + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("인증된 사용자를 찾을 수 없습니다.")); + + Optional walletOptional = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()); + int totalBalance = 0; + int giftableBalance = 0; + if (walletOptional.isPresent()) { + Wallet wallet = walletOptional.get(); + totalBalance = wallet.getBalance(); + giftableBalance = wallet.getGiftablePoint(); + } + + UserProfileResponse response = UserProfileResponse.builder() + .employeeId(employee.getEmployeeId()) + .name(employee.getEmployeeName()) + .email(employee.getEmail()) + .role(employee.getRole()) + .department(employee.getDepartment() != null ? employee.getDepartment().getName() : null) // 부서 이름 추가 + .position(employee.getPosition()) + .totalBalance(totalBalance) + .giftableBalance(giftableBalance) + .build(); + + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/WalletController.java b/src/main/java/com/joycrew/backend/controller/WalletController.java new file mode 100644 index 0000000..0a6a5b4 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/WalletController.java @@ -0,0 +1,63 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.PointBalanceResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.repository.WalletRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Optional; + +@Tag(name = "지갑", description = "포인트 관련 API") +@RestController +@RequestMapping("/api/wallet") +@RequiredArgsConstructor +public class WalletController { + + private final WalletRepository walletRepository; + + @Operation(summary = "포인트 잔액 조회", description = "현재 로그인된 사용자의 포인트 잔액을 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = PointBalanceResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 필요", + content = @Content(mediaType = "application/json", + schema = @Schema(example = "{\"message\": \"로그인이 필요합니다.\"}"))) + }) + @GetMapping("/point") + public ResponseEntity getWalletPoint(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "로그인이 필요합니다.")); + } + + String userEmail = authentication.getName(); + + Long employeeId = ((Employee) authentication.getPrincipal()).getEmployeeId(); + + Optional walletOptional = walletRepository.findByEmployee_EmployeeId(employeeId); + + int totalBalance = 0; + int giftableBalance = 0; + + if (walletOptional.isPresent()) { + Wallet wallet = walletOptional.get(); + totalBalance = wallet.getBalance(); + giftableBalance = wallet.getGiftablePoint(); + } + + return ResponseEntity.ok(new PointBalanceResponse(totalBalance, giftableBalance)); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/LoginRequest.java b/src/main/java/com/joycrew/backend/dto/LoginRequest.java new file mode 100644 index 0000000..114286d --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/LoginRequest.java @@ -0,0 +1,22 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "로그인 요청 DTO") +public class LoginRequest { + + @Schema(description = "이메일 주소", example = "user@example.com", required = true) + @Email + @NotBlank + private String email; + + @Schema(description = "비밀번호", example = "password123!", required = true) + @NotBlank + private String password; +} diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java new file mode 100644 index 0000000..97c4622 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -0,0 +1,32 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; // @Builder 어노테이션 추가 +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +@Schema(description = "로그인 응답 DTO") +public class LoginResponse { + + @Schema(description = "JWT 토큰", example = "eyJhbGciOiJIUzI1NiJ9...") + private String accessToken; + + @Schema(description = "응답 메시지 (성공/실패)", example = "로그인 성공" ) + private String message; + + @Schema(description = "사용자 고유 ID", example = "1") + private Long userId; + + @Schema(description = "사용자 이름", example = "홍길동") + private String name; + + @Schema(description = "사용자 이메일", example = "user@example.com") + private String email; + + @Schema(description = "사용자 역할", example = "EMPLOYEE", allowableValues = {"EMPLOYEE", "MANAGER", "HR_ADMIN", "SUPER_ADMIN"}) + private UserRole role; // ENUM 타입 유지 +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java new file mode 100644 index 0000000..2a0c9ad --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java @@ -0,0 +1,17 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "지갑 잔액 응답 DTO") +public class PointBalanceResponse { + + @Schema(description = "현재 잔액", example = "12000") + private Integer totalBalance; + + @Schema(description = "선물 가능한 포인트", example = "500") + private Integer giftableBalance; +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java new file mode 100644 index 0000000..785e540 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -0,0 +1,38 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +@Schema(description = "사용자 프로필 응답 DTO") +public class UserProfileResponse { + + @Schema(description = "사용자 고유 ID", example = "1") + private Long employeeId; + + @Schema(description = "사용자 이름", example = "홍길동") + private String name; + + @Schema(description = "이메일 주소", example = "user@example.com") + private String email; + + @Schema(description = "현재 총 포인트 잔액", example = "1200") + private Integer totalBalance; + + @Schema(description = "현재 선물 가능한 포인트 잔액", example = "50") + private Integer giftableBalance; + + @Schema(description = "사용자 역할", example = "EMPLOYEE", allowableValues = {"EMPLOYEE", "MANAGER", "HR_ADMIN", "SUPER_ADMIN"}) + private UserRole role; + + @Schema(description = "소속 부서", example = "개발팀", nullable = true) + private String department; + + @Schema(description = "직책", example = "대리", nullable = true) + private String position; +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java new file mode 100644 index 0000000..0750571 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -0,0 +1,52 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "company") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Company { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long companyId; + + private String companyName; + private String status; + private LocalDateTime startAt; + + @Column(nullable = false) + private Double totalCompanyBalance; + + @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) + private List employees; + + @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) + private List departments; + + @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) + private List adminAccessList; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.totalCompanyBalance == null) { + this.totalCompanyBalance = 0.0; + } + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java new file mode 100644 index 0000000..c7d9a00 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java @@ -0,0 +1,65 @@ +package com.joycrew.backend.entity; + +import com.joycrew.backend.entity.enums.AccessStatus; +import com.joycrew.backend.entity.enums.AdminLevel; +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "company_admin_access") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CompanyAdminAccess { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long accessId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "employee_id", nullable = false) + private Employee employee; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AdminLevel adminLevel; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assigned_by", nullable = true) + private Employee assignedBy; + + @Column(nullable = false) + private LocalDateTime assignedAt; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AccessStatus status; + + @Column(nullable = false) + private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.assignedAt == null) { + this.assignedAt = LocalDateTime.now(); + } + if (this.status == null) { + this.status = AccessStatus.ACTIVE; + } + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Department.java b/src/main/java/com/joycrew/backend/entity/Department.java new file mode 100644 index 0000000..25e6426 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Department.java @@ -0,0 +1,49 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "department") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Department { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long departmentId; + + @Column(nullable = false) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_head_id", nullable = true) + private Employee departmentHead; + + @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true) + private List employees; + + @Column(nullable = false) + private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java new file mode 100644 index 0000000..16faf18 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -0,0 +1,116 @@ +package com.joycrew.backend.entity; + +import com.joycrew.backend.entity.enums.UserRole; +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import java.util.Collection; +import java.util.Collections; + +@Entity +@Table(name = "employee") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Employee implements UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long employeeId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_id", nullable = true) + private Department department; + + @Column(nullable = false) + private String passwordHash; + @Column(nullable = false) + private String employeeName; + @Column(nullable = false, unique = true) + private String email; + private String position; + @Column(nullable = false) + private String status; + @Column(nullable = false) + private UserRole role; + + private LocalDateTime lastLoginAt; + @Column(nullable = false) + private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime updatedAt; + + @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Wallet wallet; + + @OneToMany(mappedBy = "sender", cascade = CascadeType.ALL, orphanRemoval = true) + private List sentTransactions; + + @OneToMany(mappedBy = "receiver", cascade = CascadeType.ALL, orphanRemoval = true) + private List receivedTransactions; + + @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true) + private List adminAccesses; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.status == null) { + this.status = "ACTIVE"; + } + if (this.role == null) { + this.role = UserRole.EMPLOYEE; + } + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + this.role)); + } + + @Override + public String getPassword() { + return this.passwordHash; + } + + @Override + public String getUsername() { + return this.email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return "ACTIVE".equals(this.status); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java new file mode 100644 index 0000000..75e3c5a --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java @@ -0,0 +1,48 @@ +package com.joycrew.backend.entity; + +import com.joycrew.backend.entity.enums.TransactionType; +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "reward_point_transaction") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RewardPointTransaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long transactionId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", nullable = true) + private Employee sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", nullable = false) + private Employee receiver; + + @Column(nullable = false) + private Integer pointAmount; + + @Lob + private String message; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TransactionType type; + + @Column(nullable = false) + private LocalDateTime transactionDate; + @Column(nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + this.createdAt = this.transactionDate = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java new file mode 100644 index 0000000..277de88 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -0,0 +1,48 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "wallet") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Wallet { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long walletId; + + @OneToOne + @JoinColumn(name = "employee_id", nullable = false, unique = true) + private Employee employee; + + @Column(nullable = false) + private Integer balance; + @Column(nullable = false) + private Integer giftablePoint; + @Column(nullable = false) + private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.balance == null) { + this.balance = 0; + } + if (this.giftablePoint == null) { + this.giftablePoint = 0; + } + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java b/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java new file mode 100644 index 0000000..abd5085 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.entity.enums; + +public enum AccessStatus { + ACTIVE, + INACTIVE, + REVOKED +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java b/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java new file mode 100644 index 0000000..1faf9f7 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.entity.enums; + +public enum AdminLevel { + SUPER_ADMIN, + HR_ADMIN, + MANAGER, + EMPLOYEE +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java b/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java new file mode 100644 index 0000000..58900fb --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.entity.enums; + +public enum TransactionType { + AWARD_P2P, + AWARD_MANAGER_SPOT, + AWARD_AUTOMATED, + REDEEM_ITEM, + ADMIN_ADJUSTMENT, + EXPIRE_POINTS +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/UserRole.java b/src/main/java/com/joycrew/backend/entity/enums/UserRole.java new file mode 100644 index 0000000..538f934 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/UserRole.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.entity.enums; + +public enum UserRole { + EMPLOYEE, + MANAGER, + HR_ADMIN, + SUPER_ADMIN +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java new file mode 100644 index 0000000..0a8799a --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Company; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CompanyRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java new file mode 100644 index 0000000..7d06509 --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Employee; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EmployeeRepository extends JpaRepository { + Optional findByEmail(String email); +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/WalletRepository.java b/src/main/java/com/joycrew/backend/repository/WalletRepository.java new file mode 100644 index 0000000..8400560 --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/WalletRepository.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Wallet; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface WalletRepository extends JpaRepository { + Optional findByEmployee_EmployeeId(Long employeeId); +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java new file mode 100644 index 0000000..86f1233 --- /dev/null +++ b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java @@ -0,0 +1,24 @@ +package com.joycrew.backend.security; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.repository.EmployeeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmployeeDetailsService implements UserDetailsService { + + private final EmployeeRepository employeeRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Employee employee = employeeRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + + return employee; + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..4ab39a9 --- /dev/null +++ b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java @@ -0,0 +1,50 @@ +package com.joycrew.backend.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + String email = jwtUtil.getEmailFromToken(token); + + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + + if (userDetails != null) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/JwtUtil.java b/src/main/java/com/joycrew/backend/security/JwtUtil.java new file mode 100644 index 0000000..10d37ef --- /dev/null +++ b/src/main/java/com/joycrew/backend/security/JwtUtil.java @@ -0,0 +1,43 @@ +package com.joycrew.backend.security; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.nio.charset.StandardCharsets; + +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secretKey; + + private final long EXPIRATION_TIME = 86400000L; + + private SecretKey getSigningKey() { + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String generateToken(String email) { + return Jwts.builder() + .setSubject(email) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String getEmailFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java new file mode 100644 index 0000000..be08034 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -0,0 +1,73 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.security.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + + private final EmployeeRepository employeeRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final AuthenticationManager authenticationManager; + + public LoginResponse login(LoginRequest request) { + log.info("Attempting login for email: {}", request.getEmail()); + + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()) + ); + + Employee employee = employeeRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new UsernameNotFoundException("사용자 정보를 찾을 수 없습니다.")); + + String accessToken = jwtUtil.generateToken(employee.getEmail()); + + return LoginResponse.builder() + .accessToken(accessToken) + .message("로그인 성공") + .userId(employee.getEmployeeId()) + .name(employee.getEmployeeName()) + .email(employee.getEmail()) + .role(employee.getRole()) + .build(); + } catch (UsernameNotFoundException | BadCredentialsException e) { + log.warn("Login failed for email {}: {}", request.getEmail(), e.getMessage()); + throw e; + } catch (Exception e) { + log.error("An unexpected error occurred during login for email {}: {}", request.getEmail(), e.getMessage(), e); + throw new RuntimeException("로그인 중 서버 오류가 발생했습니다."); + } + } + + public void logout(HttpServletRequest request) { + final String authHeader = request.getHeader("Authorization"); + final String jwt; + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + log.warn("Logout request received without a valid Bearer token."); + return; + } + + jwt = authHeader.substring(7); + log.info("Logout request received for a token. In a real application, this token should be blacklisted."); + } +} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java new file mode 100644 index 0000000..8a1829b --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -0,0 +1,55 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class EmployeeService { + + private final EmployeeRepository employeeRepository; + private final PasswordEncoder passwordEncoder; + private final WalletRepository walletRepository; + + @Transactional + public void registerEmployee(String email, String rawPassword, String name, Company company) { + if (employeeRepository.findByEmail(email).isPresent()) { + throw new RuntimeException("이미 존재하는 이메일입니다."); + } + + String encodedPassword = passwordEncoder.encode(rawPassword); + + Employee newEmployee = Employee.builder() + .email(email) + .passwordHash(encodedPassword) + .employeeName(name) + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .company(company) + .build(); + + Employee savedEmployee = employeeRepository.save(newEmployee); + + Wallet newWallet = Wallet.builder() + .employee(savedEmployee) + .balance(0) + .giftablePoint(0) + .build(); + walletRepository.save(newWallet); + + savedEmployee.setWallet(newWallet); + employeeRepository.save(savedEmployee); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 7a04d84..ecd01ed 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -9,8 +9,14 @@ spring: enabled: true jpa: hibernate: - ddl-auto: create # 또는 update - show-sql: true - properties: - hibernate: - format_sql: true \ No newline at end of file + ddl-auto: create + show-sql: false + +jwt: + secret: dev-test-secret-key-for-joycrew-backend-at-least-256-bits-long! + +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql: TRACE + com.joycrew.backend: DEBUG \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7326886..ea9917a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -6,5 +6,16 @@ spring: password: ${DB_PASSWORD} jpa: hibernate: - ddl-auto: validate # 또는 none (배포 시에는 create/update 지양) - show-sql: false \ No newline at end of file + ddl-auto: validate + show-sql: false +jwt: + secret: ${JWT_SECRET_KEY} + expiration-ms: 3600000 + +logging: + level: + com.joycrew.backend: INFO + file: + name: /var/log/joycrew/app.log + max-size: 10MB + max-history: 7 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bf6dc8c..3c54c6d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,14 +1,18 @@ server: - port: 8081 # ?? ??, ??? 8081 ??? ?? + port: 8080 spring: application: name: joycrew profiles: - active: dev # ??? H2 ?? ?? + active: dev + +jwt: + expiration-ms: 3600000 logging: + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" level: - org.hibernate.SQL: debug # ?? ?? - org.hibernate.type.descriptor.sql.BasicBinder: trace # ??? ? \ No newline at end of file + com.joycrew.backend: INFO \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java new file mode 100644 index 0000000..c136b53 --- /dev/null +++ b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java @@ -0,0 +1,46 @@ +package com.joycrew.backend.config; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.UserRole; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class TestUserDetailsService implements UserDetailsService { + + private final Map users = new HashMap<>(); + + public TestUserDetailsService() { + // Pre-populate with test users + users.put("testuser@joycrew.com", Employee.builder() + .employeeId(1L) + .email("testuser@joycrew.com") + .employeeName("테스트유저") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .passwordHash("{noop}password") // Use {noop} for plain text password in tests or your actual encoder + .build()); + + users.put("nowallet@joycrew.com", Employee.builder() + .employeeId(99L) + .email("nowallet@joycrew.com") + .employeeName("지갑없음") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .passwordHash("{noop}password") + .build()); + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + if (!users.containsKey(username)) { + throw new UsernameNotFoundException("User not found: " + username); + } + return users.get(username); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java new file mode 100644 index 0000000..0005049 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -0,0 +1,107 @@ +package com.joycrew.backend.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +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.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +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; + +@WebMvcTest(controllers = AuthController.class, + excludeAutoConfiguration = {SecurityAutoConfiguration.class}) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private AuthService authService; + + @Test + @DisplayName("POST /api/auth/login - 로그인 성공") + void login_Success() throws Exception { + LoginRequest request = new LoginRequest(); + request.setEmail("test@joycrew.com"); + request.setPassword("password123!"); + + LoginResponse successResponse = LoginResponse.builder() + .accessToken("mocked.jwt.token") + .message("로그인 성공") + .build(); + + when(authService.login(any(LoginRequest.class))).thenReturn(successResponse); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").value("mocked.jwt.token")) + .andExpect(jsonPath("$.message").value("로그인 성공")); + } + + @Test + @DisplayName("POST /api/auth/login - 로그인 실패 (잘못된 비밀번호)") + void login_Failure_WrongPassword() throws Exception { + LoginRequest request = new LoginRequest(); + request.setEmail("test@joycrew.com"); + request.setPassword("wrongpassword"); + + when(authService.login(any(LoginRequest.class))) + .thenThrow(new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다.")); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.accessToken").isEmpty()) + .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 올바르지 않습니다.")); + } + + @Test + @DisplayName("POST /api/auth/login - 로그인 실패 (이메일 없음)") + void login_Failure_EmailNotFound() throws Exception { + LoginRequest request = new LoginRequest(); + request.setEmail("nonexistent@joycrew.com"); + request.setPassword("anypassword"); + + when(authService.login(any(LoginRequest.class))) + .thenThrow(new UsernameNotFoundException("이메일 또는 비밀번호가 올바르지 않습니다.")); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.accessToken").isEmpty()) + .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 올바르지 않습니다.")); + } + + @Test + @DisplayName("POST /api/auth/logout - 로그아웃 성공") + void logout_Success() throws Exception { + doNothing().when(authService).logout(any(HttpServletRequest.class)); + + mockMvc.perform(post("/api/auth/logout") + // 실제 요청처럼 Authorization 헤더를 포함하여 테스트 + .header("Authorization", "Bearer some.mock.token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java new file mode 100644 index 0000000..031b436 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -0,0 +1,105 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.Map; +import java.util.Optional; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = UserController.class) +@Import(UserControllerTest.TestControllerAdvice.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private EmployeeRepository employeeRepository; + @MockBean + private WalletRepository walletRepository; + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; + + @ControllerAdvice + static class TestControllerAdvice { + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", e.getMessage())); + } + } + + private Employee testEmployee; + private Wallet testWallet; + + @BeforeEach + void setUp() { + testEmployee = Employee.builder() + .employeeId(1L) + .email("testuser@joycrew.com") + .employeeName("테스트유저") + .role(UserRole.EMPLOYEE) + .build(); + + testWallet = Wallet.builder() + .balance(1500) + .giftablePoint(100) + .build(); + } + + @Test + @DisplayName("GET /api/user/profile - 프로필 조회 성공 (인증된 사용자)") + @WithMockUser(username = "testuser@joycrew.com") + void getProfile_Success_AuthenticatedUser() throws Exception { + when(employeeRepository.findByEmail("testuser@joycrew.com")).thenReturn(Optional.of(testEmployee)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(testWallet)); + + mockMvc.perform(get("/api/user/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("테스트유저")); + } + + @Test + @DisplayName("GET /api/user/profile - 프로필 조회 실패 (인증되지 않은 사용자)") + void getProfile_Failure_Unauthenticated() throws Exception { + mockMvc.perform(get("/api/user/profile")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /api/user/profile - 프로필 조회 실패 (인증은 되었으나 사용자 정보 없음)") + @WithMockUser(username = "nonexistent@joycrew.com") + void getProfile_Failure_UserNotFoundAfterAuth() throws Exception { + when(employeeRepository.findByEmail("nonexistent@joycrew.com")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/user/profile")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("인증된 사용자를 찾을 수 없습니다.")); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java new file mode 100644 index 0000000..37882f1 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java @@ -0,0 +1,116 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import 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.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.Optional; + +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +class WalletControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private WalletRepository walletRepository; + + @Autowired + private WebApplicationContext context; + + private Employee testEmployee; + private Wallet testWallet; + private Employee noWalletEmployee; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + + testEmployee = Employee.builder() + .employeeId(1L) + .email("testuser@joycrew.com") + .employeeName("테스트유저") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .passwordHash("{noop}password") + .build(); + + testWallet = Wallet.builder() + .walletId(100L) + .employee(testEmployee) + .balance(1500) + .giftablePoint(100) + .build(); + + noWalletEmployee = Employee.builder() + .employeeId(99L) + .email("nowallet@joycrew.com") + .passwordHash("{noop}password") + .employeeName("지갑없음") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .build(); + } + + @Test + @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 성공 (인증된 사용자)") + @WithUserDetails(value = "testuser@joycrew.com", userDetailsServiceBeanName = "testUserDetailsService") + void getWalletPoint_Success_WithValidToken() throws Exception { + when(walletRepository.findByEmployee_EmployeeId(testEmployee.getEmployeeId())) + .thenReturn(Optional.of(testWallet)); + + mockMvc.perform(get("/api/wallet/point") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.totalBalance").value(testWallet.getBalance())) + .andExpect(jsonPath("$.giftableBalance").value(testWallet.getGiftablePoint())); + } + + @Test + @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 실패 (인증되지 않은 사용자)") + void getWalletPoint_Failure_Unauthenticated() throws Exception { + mockMvc.perform(get("/api/wallet/point") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value("로그인이 필요합니다.")); + } + + @Test + @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 성공 (인증은 되었으나 지갑 없음)") + @WithUserDetails(value = "nowallet@joycrew.com", userDetailsServiceBeanName = "testUserDetailsService") + void getWalletPoint_Success_WalletNotFound() throws Exception { + when(walletRepository.findByEmployee_EmployeeId(noWalletEmployee.getEmployeeId())) + .thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/wallet/point") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.totalBalance").value(0)) + .andExpect(jsonPath("$.giftableBalance").value(0)); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java new file mode 100644 index 0000000..4870557 --- /dev/null +++ b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java @@ -0,0 +1,126 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class EmployeeRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + @Autowired + private EmployeeRepository employeeRepository; + @Autowired + private WalletRepository walletRepository; + + private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + private Company testCompany; + private Department testDepartment; + private Employee testEmployee; + + @BeforeEach + void setUp() { + testCompany = Company.builder() + .companyName("테스트회사") + .status("ACTIVE") + .startAt(LocalDateTime.now()) + .totalCompanyBalance(0.0) + .build(); + entityManager.persist(testCompany); + + testDepartment = Department.builder() + .name("테스트부서") + .company(testCompany) + .build(); + entityManager.persist(testDepartment); + + testEmployee = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("test@joycrew.com") + .passwordHash(passwordEncoder.encode("password123")) + .employeeName("김테스트") + .position("사원") + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .lastLoginAt(null) + .build(); + entityManager.persist(testEmployee); + + Wallet testWallet = Wallet.builder() + .employee(testEmployee) + .balance(1000) + .giftablePoint(100) + .build(); + entityManager.persist(testWallet); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("이메일로 직원 조회 성공") + void findByEmail_Success() { + // When + Optional foundEmployee = employeeRepository.findByEmail("test@joycrew.com"); + + // Then + assertThat(foundEmployee).isPresent(); + assertThat(foundEmployee.get().getEmail()).isEqualTo("test@joycrew.com"); + assertThat(foundEmployee.get().getEmployeeName()).isEqualTo("김테스트"); + assertThat(foundEmployee.get().getCompany().getCompanyName()).isEqualTo("테스트회사"); + assertThat(foundEmployee.get().getDepartment().getName()).isEqualTo("테스트부서"); + } + + @Test + @DisplayName("이메일로 직원 조회 실패 - 존재하지 않는 이메일") + void findByEmail_NotFound() { + // When + Optional foundEmployee = employeeRepository.findByEmail("nonexistent@joycrew.com"); + + // Then + assertThat(foundEmployee).isEmpty(); + } + + @Test + @DisplayName("Employee 저장 및 조회 성공") + void saveAndFindEmployee() { + // Given + Employee newEmployee = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("new@joycrew.com") + .passwordHash(passwordEncoder.encode("newpass")) + .employeeName("새로운직원") + .position("대리") + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .build(); + + // When + Employee savedEmployee = employeeRepository.save(newEmployee); + Optional found = employeeRepository.findById(savedEmployee.getEmployeeId()); + + // Then + assertThat(found).isPresent(); + assertThat(found.get().getEmail()).isEqualTo("new@joycrew.com"); + assertThat(found.get().getEmployeeName()).isEqualTo("새로운직원"); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java new file mode 100644 index 0000000..21eb203 --- /dev/null +++ b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java @@ -0,0 +1,160 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class WalletRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + @Autowired + private EmployeeRepository employeeRepository; + @Autowired + private WalletRepository walletRepository; + + private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + private Company testCompany; + private Department testDepartment; + private Employee testEmployeeWithWallet; + private Employee testEmployeeWithoutWallet; + private Wallet testWallet; + + @BeforeEach + void setUp() { + testCompany = Company.builder() + .companyName("테스트회사") + .status("ACTIVE") + .startAt(LocalDateTime.now()) + .totalCompanyBalance(0.0) + .build(); + entityManager.persist(testCompany); + + testDepartment = Department.builder() + .name("테스트부서") + .company(testCompany) + .build(); + entityManager.persist(testDepartment); + + testEmployeeWithWallet = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("walletuser@joycrew.com") + .passwordHash(passwordEncoder.encode("pass123")) + .employeeName("지갑유저") + .position("선임") + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .build(); + entityManager.persist(testEmployeeWithWallet); + + testEmployeeWithoutWallet = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("nowallet@joycrew.com") + .passwordHash(passwordEncoder.encode("pass123")) + .employeeName("지갑없는유저") + .position("주니어") + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .build(); + entityManager.persist(testEmployeeWithoutWallet); + + + testWallet = Wallet.builder() + .employee(testEmployeeWithWallet) + .balance(5000) + .giftablePoint(500) + .build(); + entityManager.persist(testWallet); + + testEmployeeWithWallet.setWallet(testWallet); + entityManager.merge(testEmployeeWithWallet); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("Employee ID로 Wallet 조회 성공") + void findByEmployee_EmployeeId_Success() { + // When + Optional foundWallet = walletRepository.findByEmployee_EmployeeId(testEmployeeWithWallet.getEmployeeId()); + + // Then + assertThat(foundWallet).isPresent(); + assertThat(foundWallet.get().getEmployee().getEmployeeId()).isEqualTo(testEmployeeWithWallet.getEmployeeId()); + assertThat(foundWallet.get().getBalance()).isEqualTo(5000); + assertThat(foundWallet.get().getGiftablePoint()).isEqualTo(500); + } + + @Test + @DisplayName("Employee ID로 Wallet 조회 실패 - Wallet 없음") + void findByEmployee_EmployeeId_NotFound() { + // When + Optional foundWallet = walletRepository.findByEmployee_EmployeeId(testEmployeeWithoutWallet.getEmployeeId()); + + // Then + assertThat(foundWallet).isEmpty(); + } + + @Test + @DisplayName("Wallet 저장 및 조회 성공") + void saveAndFindWallet() { + // Given + Employee anotherEmployee = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("another@joycrew.com") + .passwordHash(passwordEncoder.encode("pass456")) + .employeeName("다른직원") + .position("팀장") + .status("ACTIVE") + .role(UserRole.MANAGER) + .build(); + entityManager.persist(anotherEmployee); + + Wallet newWallet = Wallet.builder() + .employee(anotherEmployee) + .balance(2000) + .giftablePoint(200) + .build(); + + // When + Wallet savedWallet = walletRepository.save(newWallet); + Optional found = walletRepository.findById(savedWallet.getWalletId()); + + // Then + assertThat(found).isPresent(); + assertThat(found.get().getBalance()).isEqualTo(2000); + assertThat(found.get().getEmployee().getEmployeeId()).isEqualTo(anotherEmployee.getEmployeeId()); + + // 양방향 관계 업데이트 + anotherEmployee.setWallet(savedWallet); + entityManager.merge(anotherEmployee); + entityManager.flush(); + entityManager.clear(); + + Optional foundEmployee = employeeRepository.findById(anotherEmployee.getEmployeeId()); + assertThat(foundEmployee).isPresent(); + assertThat(foundEmployee.get().getWallet()).isNotNull(); + assertThat(foundEmployee.get().getWallet().getWalletId()).isEqualTo(savedWallet.getWalletId()); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java new file mode 100644 index 0000000..3d7a8af --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -0,0 +1,112 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.JoyCrewBackendApplication; +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.security.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = JoyCrewBackendApplication.class) +@ActiveProfiles("dev") +@Transactional +class AuthServiceIntegrationTest { + + @Autowired + private AuthService authService; + @Autowired + private EmployeeService employeeService; + @Autowired + private EmployeeRepository employeeRepository; + @Autowired + private JwtUtil jwtUtil; + @Autowired + private CompanyRepository companyRepository; + + private String testEmail = "integration@joycrew.com"; + private String testPassword = "integrationPass123!"; + private String testName = "통합테스트유저"; + private Company defaultCompany; + + @BeforeEach + void setUp() { + defaultCompany = Company.builder() + .companyName("테스트컴퍼니") + .status("ACTIVE") + .startAt(LocalDateTime.now()) + .totalCompanyBalance(0.0) + .build(); + defaultCompany = companyRepository.save(defaultCompany); + + employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); + + employeeService.registerEmployee(testEmail, testPassword, testName, defaultCompany); + } + + @Test + @DisplayName("통합 테스트: 로그인 성공 시 JWT 토큰과 사용자 정보 반환") + void login_Integration_Success() { + // Given + LoginRequest request = new LoginRequest(); + request.setEmail(testEmail); + request.setPassword(testPassword); + + // When + LoginResponse response = authService.login(request); + + // Then + assertThat(response).isNotNull(); + assertThat(response.getAccessToken()).isNotBlank(); + assertThat(response.getMessage()).isEqualTo("로그인 성공"); + assertThat(response.getEmail()).isEqualTo(testEmail); + assertThat(response.getUserId()).isEqualTo(employeeRepository.findByEmail(testEmail).get().getEmployeeId()); + assertThat(response.getName()).isEqualTo(testName); + assertThat(response.getRole()).isEqualTo(UserRole.EMPLOYEE); + + String extractedEmail = jwtUtil.getEmailFromToken(response.getAccessToken()); + assertThat(extractedEmail).isEqualTo(testEmail); + } + + @Test + @DisplayName("통합 테스트: 로그인 실패 - 존재하지 않는 이메일") + void login_Integration_Failure_EmailNotFound() { + // Given + LoginRequest request = new LoginRequest(); + request.setEmail("nonexistent@joycrew.com"); + request.setPassword("anypassword"); + + // When & Then + // 발생하는 예외의 종류만 확인하도록 수정 + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BadCredentialsException.class); + } + + @Test + @DisplayName("통합 테스트: 로그인 실패 - 비밀번호 불일치") + void login_Integration_Failure_WrongPassword() { + // Given + LoginRequest request = new LoginRequest(); + request.setEmail(testEmail); + request.setPassword("wrongpassword"); + + // When & Then + // 발생하는 예외의 종류만 확인하도록 수정 + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BadCredentialsException.class); + } +} diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java new file mode 100644 index 0000000..a78e633 --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -0,0 +1,136 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.security.JwtUtil; +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.mockito.quality.Strictness; +import org.mockito.junit.jupiter.MockitoSettings; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +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.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AuthServiceTest { + + @Mock + private EmployeeRepository employeeRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private JwtUtil jwtUtil; + @Mock + private AuthenticationManager authenticationManager; + + @InjectMocks + private AuthService authService; + + private Employee testEmployee; + private LoginRequest testLoginRequest; + private String encodedPassword; + private String testToken = "mocked.jwt.token"; + + @BeforeEach + void setUp() { + encodedPassword = new BCryptPasswordEncoder().encode("password123"); + + testEmployee = Employee.builder() + .employeeId(1L) + .email("test@joycrew.com") + .passwordHash(encodedPassword) + .employeeName("테스트유저") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .build(); + + testLoginRequest = new LoginRequest(); + testLoginRequest.setEmail("test@joycrew.com"); + testLoginRequest.setPassword("password123"); + } + + @Test + @DisplayName("로그인 성공 시 JWT 토큰과 사용자 정보 반환") + void login_Success() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(mock(Authentication.class)); + when(employeeRepository.findByEmail(testLoginRequest.getEmail())).thenReturn(Optional.of(testEmployee)); + when(jwtUtil.generateToken(anyString())).thenReturn(testToken); + + // When + LoginResponse response = authService.login(testLoginRequest); + + // Then + assertThat(response).isNotNull(); + assertThat(response.getAccessToken()).isEqualTo(testToken); + assertThat(response.getMessage()).isEqualTo("로그인 성공"); + assertThat(response.getUserId()).isEqualTo(testEmployee.getEmployeeId()); + assertThat(response.getEmail()).isEqualTo(testEmployee.getEmail()); + assertThat(response.getRole()).isEqualTo(testEmployee.getRole()); + + // 메서드 호출 검증 + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(employeeRepository, times(1)).findByEmail(testLoginRequest.getEmail()); + verify(jwtUtil, times(1)).generateToken(testEmployee.getEmail()); + } + + @Test + @DisplayName("로그인 실패 - 이메일 없음 (UsernameNotFoundException)") + void login_Failure_EmailNotFound() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new UsernameNotFoundException("이메일 또는 비밀번호가 올바르지 않습니다.")); + + // When & Then + assertThatThrownBy(() -> authService.login(testLoginRequest)) + .isInstanceOf(UsernameNotFoundException.class) + .hasMessageContaining("이메일 또는 비밀번호가 올바르지 않습니다."); + + // 메서드 호출 검증 + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(employeeRepository, never()).findByEmail(anyString()); + verify(passwordEncoder, never()).matches(anyString(), anyString()); + verify(jwtUtil, never()).generateToken(anyString()); + } + + @Test + @DisplayName("로그인 실패 - 비밀번호 불일치 (BadCredentialsException)") + void login_Failure_WrongPassword() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다.")); + + // When & Then + assertThatThrownBy(() -> authService.login(testLoginRequest)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("이메일 또는 비밀번호가 올바르지 않습니다."); + + // 메서드 호출 검증 + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(employeeRepository, never()).findByEmail(anyString()); + verify(passwordEncoder, never()).matches(anyString(), anyString()); + verify(jwtUtil, never()).generateToken(anyString()); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java new file mode 100644 index 0000000..16cbc3f --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -0,0 +1,107 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.JoyCrewBackendApplication; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = JoyCrewBackendApplication.class) +@Transactional +class EmployeeServiceIntegrationTest { + + @Autowired + private EmployeeService employeeService; + @Autowired + private EmployeeRepository employeeRepository; + @Autowired + private WalletRepository walletRepository; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private CompanyRepository companyRepository; + + private String testEmail = "integration_new@joycrew.com"; + private String testPassword = "newPass123!"; + private String testName = "새통합유저"; + private Company defaultCompany; + private Employee registeredEmployee; // <-- setUp에서 등록된 Employee를 저장할 필드 추가 + + @BeforeEach + void setUp() { + defaultCompany = Company.builder() + .companyName("테스트컴퍼니2") + .status("ACTIVE") + .startAt(LocalDateTime.now()) + .totalCompanyBalance(0.0) + .build(); + defaultCompany = companyRepository.save(defaultCompany); + + employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); + + // --- setUp에서 Employee를 등록하고 필드에 저장 --- + employeeService.registerEmployee(testEmail, testPassword, testName, defaultCompany); + registeredEmployee = employeeRepository.findByEmail(testEmail).orElseThrow(); // 등록된 Employee 조회 + // --- 수정 끝 --- + } + + @Test + @DisplayName("통합 테스트: 직원 등록 성공 및 Wallet 자동 생성 확인") + void registerEmployee_Integration_Success_And_WalletCreated() { + // Given + // 이 테스트는 새로운 직원을 등록하는 것이 아니라, setUp에서 등록된 직원의 상태를 확인하는 테스트로 변경 + // 또는, 새로운 이메일을 가진 직원을 등록하는 테스트로 변경 + String newTestEmailForSuccess = "success_test@joycrew.com"; + employeeRepository.findByEmail(newTestEmailForSuccess).ifPresent(employeeRepository::delete); // 혹시 모를 잔여 데이터 삭제 + + // When + employeeService.registerEmployee(newTestEmailForSuccess, "successPass123", "성공유저", defaultCompany); + + // Then + Optional savedEmployeeOptional = employeeRepository.findByEmail(newTestEmailForSuccess); + assertThat(savedEmployeeOptional).isPresent(); + Employee savedEmployee = savedEmployeeOptional.get(); + + assertThat(savedEmployee.getEmployeeName()).isEqualTo("성공유저"); + assertThat(passwordEncoder.matches("successPass123", savedEmployee.getPasswordHash())).isTrue(); + assertThat(savedEmployee.getRole()).isEqualTo(UserRole.EMPLOYEE); + assertThat(savedEmployee.getStatus()).isEqualTo("ACTIVE"); + + Optional savedWalletOptional = walletRepository.findByEmployee_EmployeeId(savedEmployee.getEmployeeId()); + assertThat(savedWalletOptional).isPresent(); + Wallet savedWallet = savedWalletOptional.get(); + + assertThat(savedWallet.getEmployee().getEmployeeId()).isEqualTo(savedEmployee.getEmployeeId()); + assertThat(savedWallet.getBalance()).isEqualTo(0); + assertThat(savedWallet.getGiftablePoint()).isEqualTo(0); + } + + @Test + @DisplayName("통합 테스트: 직원 등록 실패 - 이메일 중복") + void registerEmployee_Integration_Failure_EmailDuplicate() { + // Given + // setUp에서 이미 testEmail로 직원이 등록되어 있음 + + // When & Then + // 동일한 이메일로 다시 등록 시도 시 예외 발생 확인 + assertThatThrownBy(() -> employeeService.registerEmployee(testEmail, "anotherPass", "다른이름", defaultCompany)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("이미 존재하는 이메일입니다."); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java new file mode 100644 index 0000000..cb5069b --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java @@ -0,0 +1,107 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +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.mockito.quality.Strictness; +import org.mockito.junit.jupiter.MockitoSettings; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class EmployeeServiceTest { + + @Mock + private EmployeeRepository employeeRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private Company mockCompany; + @Mock + private WalletRepository walletRepository; + + @InjectMocks + private EmployeeService employeeService; + + @BeforeEach + void setUp() { + employeeService = new EmployeeService(employeeRepository, passwordEncoder, walletRepository); + + when(mockCompany.getCompanyId()).thenReturn(1L); + } + + @Test + @DisplayName("직원 등록 성공") + void registerEmployee_Success() { + // Given + String email = "newuser@joycrew.com"; + String rawPassword = "newpassword123"; + String name = "새로운직원"; + String encodedPassword = "encodedPasswordHash"; + + when(employeeRepository.findByEmail(email)).thenReturn(Optional.empty()); + when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword); + + when(employeeRepository.save(any(Employee.class))).thenAnswer(invocation -> { + Employee savedEmployee = invocation.getArgument(0); + if (savedEmployee.getEmployeeId() == null) { + savedEmployee.setEmployeeId(2L); + } + return savedEmployee; + }); + + when(walletRepository.save(any(Wallet.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + employeeService.registerEmployee(email, rawPassword, name, mockCompany); + + // Then + verify(employeeRepository, times(2)).save(any(Employee.class)); + verify(employeeRepository, times(2)).save(argThat(employee -> + employee.getEmail().equals(email) && + employee.getPasswordHash().equals(encodedPassword) && + employee.getEmployeeName().equals(name) && + employee.getStatus().equals("ACTIVE") && + employee.getRole().equals(UserRole.EMPLOYEE) && + employee.getCompany().getCompanyId().equals(mockCompany.getCompanyId()) + )); + verify(passwordEncoder, times(1)).encode(rawPassword); + verify(walletRepository, times(1)).save(any(Wallet.class)); + } + + @Test + @DisplayName("직원 등록 실패 - 이메일 중복") + void registerEmployee_Failure_EmailDuplicate() { + // Given + String email = "existing@joycrew.com"; + String rawPassword = "password123"; + String name = "기존직원"; + + when(employeeRepository.findByEmail(email)).thenReturn(Optional.of(Employee.builder().email(email).build())); + + // When & Then + assertThatThrownBy(() -> employeeService.registerEmployee(email, rawPassword, name, mockCompany)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("이미 존재하는 이메일입니다."); + + verify(employeeRepository, never()).save(any(Employee.class)); + verify(passwordEncoder, never()).encode(anyString()); + verify(walletRepository, never()).save(any(Wallet.class)); + } +} \ No newline at end of file