diff --git a/app/build.gradle b/app/build.gradle index 86036c9..dcc79ef 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,10 +10,11 @@ dependencies { testImplementation 'org.springframework:spring-tx' + testImplementation 'org.springframework.boot:spring-boot-starter-security' testRuntimeOnly 'com.h2database:h2' - runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'org.postgresql:postgresql' developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/app/src/main/resources/application.yml b/app/src/main/resources/application.yml index 1257420..87bd519 100644 --- a/app/src/main/resources/application.yml +++ b/app/src/main/resources/application.yml @@ -13,7 +13,7 @@ spring: # Database Configuration (MySQL) # =================================================================== datasource: - driver-class-name: com.mysql.cj.jdbc.Driver + driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 10 @@ -40,7 +40,7 @@ spring: hibernate: format_sql: true highlight_sql: true - dialect: org.hibernate.dialect.MySQLDialect + dialect: org.hibernate.dialect.PostgreSQLDialect jdbc: batch_size: 20 @@ -72,9 +72,15 @@ server: # =================================================================== springdoc: api-docs: - path: /api-docs + path: docs/openapi/api-spec.yaml swagger-ui: path: /swagger-ui.html display-request-duration: true groups-order: asc show-actuator: false + + +jwt: + secret: dev-secret-key-for-jwt-token-generation-minimum-256-bits-required-for-hs256-algorithm-do-not-use-in-production + access-token-validity-in-seconds: 3600 # 1 hour + refresh-token-validity-in-seconds: 604800 # 7 days diff --git a/app/src/test/java/com/btg/e2e/AuthControllerE2ETest.java b/app/src/test/java/com/btg/e2e/AuthControllerE2ETest.java new file mode 100644 index 0000000..e719e1a --- /dev/null +++ b/app/src/test/java/com/btg/e2e/AuthControllerE2ETest.java @@ -0,0 +1,338 @@ +package com.btg.e2e; + +import com.btg.core.application.port.in.dailyprogress.GetDailyProgressUseCase; +import com.btg.core.application.port.in.dailyprogress.UpdateDailyProgressUseCase; +import com.btg.core.application.port.in.group.*; +import com.btg.core.application.port.in.task.*; +import com.btg.core.application.port.in.user.GetUserProfileUseCase; +import com.btg.core.application.port.in.user.UpdateUserProfileUseCase; +import com.btg.infrastructure.persistence.auth.repository.RefreshTokenJpaRepository; +import com.btg.infrastructure.persistence.user.repository.UserJpaRepository; +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.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@DisplayName("Auth Controller E2E Tests (Real Server)") +class AuthControllerE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private RefreshTokenJpaRepository refreshTokenJpaRepository; + + @MockBean private GetDailyProgressUseCase getDailyProgressUseCase; + @MockBean private UpdateDailyProgressUseCase updateDailyProgressUseCase; + @MockBean private CreateTaskUseCase createTaskUseCase; + @MockBean private GetTaskUseCase getTaskUseCase; + @MockBean private UpdateTaskUseCase updateTaskUseCase; + @MockBean private DeleteTaskUseCase deleteTaskUseCase; + @MockBean private ListTasksUseCase listTasksUseCase; + @MockBean private CreateGroupUseCase createGroupUseCase; + @MockBean private GetGroupUseCase getGroupUseCase; + @MockBean private UpdateGroupUseCase updateGroupUseCase; + @MockBean private DeleteGroupUseCase deleteGroupUseCase; + @MockBean private ListGroupsUseCase listGroupsUseCase; + @MockBean private JoinGroupUseCase joinGroupUseCase; + @MockBean private GetUserProfileUseCase getUserProfileUseCase; + @MockBean private UpdateUserProfileUseCase updateUserProfileUseCase; + + @BeforeEach + void setUp() { + refreshTokenJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); + } + + @Test + @DisplayName("POST /auth/signup - Success with real database persistence") + void signup_Success_WithDatabasePersistence() { + // Given + String requestBody = """ + { + "email": "newuser@example.com", + "password": "securePassword123", + "name": "New User" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + // When + var response = restTemplate.postForEntity("/auth/signup", request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).contains("newuser@example.com"); + assertThat(response.getBody()).contains("New User"); + + // Verify database persistence + var savedUser = userJpaRepository.findByEmail("newuser@example.com"); + assertThat(savedUser).isPresent(); + assertThat(savedUser.get().getName()).isEqualTo("New User"); + // Password should be encrypted (not plain text) + assertThat(savedUser.get().getPassword()).isNotEqualTo("securePassword123"); + assertThat(savedUser.get().getPassword()).startsWith("$2a$"); // BCrypt prefix + } + + @Test + @DisplayName("POST /auth/signup - Duplicate email should fail") + void signup_Fail_DuplicateEmail() { + // Given: Create first user + String firstUserRequest = """ + { + "email": "duplicate@example.com", + "password": "password123", + "name": "First User" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + restTemplate.postForEntity("/auth/signup", new HttpEntity<>(firstUserRequest, headers), String.class); + + // When: Try to create user with same email + String duplicateRequest = """ + { + "email": "duplicate@example.com", + "password": "differentPassword", + "name": "Second User" + } + """; + + var response = restTemplate.postForEntity("/auth/signup", new HttpEntity<>(duplicateRequest, headers), String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("POST /auth/login - Success with real JWT token generation") + void login_Success_WithRealJwtToken() { + // Given: Create a user first + String signupRequest = """ + { + "email": "logintest@example.com", + "password": "myPassword123", + "name": "Login Test User" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + restTemplate.postForEntity("/auth/signup", new HttpEntity<>(signupRequest, headers), String.class); + + // When: Login + String loginRequest = """ + { + "email": "logintest@example.com", + "password": "myPassword123" + } + """; + + var response = restTemplate.postForEntity("/auth/login", new HttpEntity<>(loginRequest, headers), String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("accessToken"); + assertThat(response.getBody()).contains("refreshToken"); + assertThat(response.getBody()).contains("logintest@example.com"); + assertThat(response.getBody()).contains("Login Test User"); + + // Verify tokens are real JWT format (header.payload.signature) + assertThat(response.getBody()).containsPattern("\"accessToken\":\"[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\""); + assertThat(response.getBody()).containsPattern("\"refreshToken\":\"[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\""); + + // Verify refresh token is stored in database + String refreshToken = extractRefreshToken(response.getBody()); + var savedToken = refreshTokenJpaRepository.findByToken(refreshToken); + assertThat(savedToken).isPresent(); + } + + @Test + @DisplayName("POST /auth/login - Wrong password should fail") + void login_Fail_WrongPassword() { + // Given: Create a user first + String signupRequest = """ + { + "email": "wrongpw@example.com", + "password": "correctPassword", + "name": "Test User" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + restTemplate.postForEntity("/auth/signup", new HttpEntity<>(signupRequest, headers), String.class); + + // When: Try to login with wrong password + String loginRequest = """ + { + "email": "wrongpw@example.com", + "password": "wrongPassword" + } + """; + + var response = restTemplate.postForEntity("/auth/login", new HttpEntity<>(loginRequest, headers), String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("POST /auth/refresh - Success with new access token") + void refreshToken_Success() { + // Given: Signup and login to get refresh token + String signupRequest = """ + { + "email": "refresh@example.com", + "password": "password123", + "name": "Refresh User" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + restTemplate.postForEntity("/auth/signup", new HttpEntity<>(signupRequest, headers), String.class); + + String loginRequest = """ + { + "email": "refresh@example.com", + "password": "password123" + } + """; + + var loginResponse = restTemplate.postForEntity("/auth/login", new HttpEntity<>(loginRequest, headers), String.class); + String refreshToken = extractRefreshToken(loginResponse.getBody()); + + // When: Refresh access token + String refreshRequest = String.format(""" + { + "refreshToken": "%s" + } + """, refreshToken); + + var response = restTemplate.postForEntity("/auth/refresh", new HttpEntity<>(refreshRequest, headers), String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("accessToken"); + // New access token should be different from original + assertThat(response.getBody()).containsPattern("\"accessToken\":\"[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\""); + } + + @Test + @DisplayName("POST /auth/logout - Success and token removed from database") + void logout_Success_TokenRemovedFromDatabase() { + // Given: Signup and login + String signupRequest = """ + { + "email": "logout@example.com", + "password": "password123", + "name": "Logout User" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + restTemplate.postForEntity("/auth/signup", new HttpEntity<>(signupRequest, headers), String.class); + + String loginRequest = """ + { + "email": "logout@example.com", + "password": "password123" + } + """; + + var loginResponse = restTemplate.postForEntity("/auth/login", new HttpEntity<>(loginRequest, headers), String.class); + String refreshToken = extractRefreshToken(loginResponse.getBody()); + + // Verify token exists before logout + assertThat(refreshTokenJpaRepository.findByToken(refreshToken)).isPresent(); + + // When: Logout + String logoutRequest = String.format(""" + { + "refreshToken": "%s" + } + """, refreshToken); + + var response = restTemplate.postForEntity("/auth/logout", new HttpEntity<>(logoutRequest, headers), String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + // Verify token is removed from database + assertThat(refreshTokenJpaRepository.findByToken(refreshToken)).isEmpty(); + } + + @Test + @DisplayName("POST /auth/signup - Validation error for invalid email") + void signup_ValidationError_InvalidEmail() { + // Given + String requestBody = """ + { + "email": "not-an-email", + "password": "password123", + "name": "Test User" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + // When + var response = restTemplate.postForEntity("/auth/signup", request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("POST /auth/signup - Validation error for short password") + void signup_ValidationError_ShortPassword() { + // Given + String requestBody = """ + { + "email": "test@example.com", + "password": "short", + "name": "Test User" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + // When + var response = restTemplate.postForEntity("/auth/signup", request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + // Helper method to extract refresh token from JSON response + private String extractRefreshToken(String jsonResponse) { + int startIndex = jsonResponse.indexOf("\"refreshToken\":\"") + 16; + int endIndex = jsonResponse.indexOf("\"", startIndex); + return jsonResponse.substring(startIndex, endIndex); + } +} diff --git a/app/src/test/java/com/btg/e2e/UserControllerE2ETest.java b/app/src/test/java/com/btg/e2e/UserControllerE2ETest.java new file mode 100644 index 0000000..2ffb9e2 --- /dev/null +++ b/app/src/test/java/com/btg/e2e/UserControllerE2ETest.java @@ -0,0 +1,325 @@ +package com.btg.e2e; + +import com.btg.core.application.port.in.dailyprogress.GetDailyProgressUseCase; +import com.btg.core.application.port.in.dailyprogress.UpdateDailyProgressUseCase; +import com.btg.core.application.port.in.group.*; +import com.btg.core.application.port.in.task.*; +import com.btg.infrastructure.persistence.user.entity.UserJpaEntity; +import com.btg.infrastructure.persistence.user.repository.UserJpaRepository; +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.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@DisplayName("User Controller E2E Tests (Real Server)") +class UserControllerE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean private GetDailyProgressUseCase getDailyProgressUseCase; + @MockBean private UpdateDailyProgressUseCase updateDailyProgressUseCase; + @MockBean private CreateTaskUseCase createTaskUseCase; + @MockBean private GetTaskUseCase getTaskUseCase; + @MockBean private UpdateTaskUseCase updateTaskUseCase; + @MockBean private DeleteTaskUseCase deleteTaskUseCase; + @MockBean private ListTasksUseCase listTasksUseCase; + @MockBean private CreateGroupUseCase createGroupUseCase; + @MockBean private GetGroupUseCase getGroupUseCase; + @MockBean private UpdateGroupUseCase updateGroupUseCase; + @MockBean private DeleteGroupUseCase deleteGroupUseCase; + @MockBean private ListGroupsUseCase listGroupsUseCase; + @MockBean private JoinGroupUseCase joinGroupUseCase; + + private UserJpaEntity testUser; + + @BeforeEach + void setUp() { + // 1. 기존 데이터 정리 + userJpaRepository.deleteAll(); + + // 2. H2 AUTO_INCREMENT 초기화 (ID를 1부터 시작) + jdbcTemplate.execute("ALTER TABLE users ALTER COLUMN id RESTART WITH 1"); + + // 3. 테스트 사용자 생성 (이제 ID=1로 저장됨) + testUser = new UserJpaEntity( + "testuser@example.com", + passwordEncoder.encode("password123"), + "Test User" + ); + testUser = userJpaRepository.save(testUser); + + // 4. 디버깅: 실제 저장된 ID 확인 + System.out.println("✅ Test user created with ID: " + testUser.getId()); + System.out.println("✅ User count in DB: " + userJpaRepository.count()); + } + + @Test + @DisplayName("GET /users/me - Success with real database") + void getMyProfile_Success_WithRealDatabase() { + // When + var response = restTemplate.getForEntity("/users/me", String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("testuser@example.com"); + assertThat(response.getBody()).contains("Test User"); + assertThat(response.getBody()).contains("\"id\":" + testUser.getId()); + } + + @Test + @DisplayName("PUT /users/me - Success (Update Name Only)") + void updateMyProfile_Success_NameOnly() { + // Given + String requestBody = """ + { + "name": "Updated Name" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + // When + var response = restTemplate.exchange("/users/me", HttpMethod.PUT, request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("Updated Name"); + assertThat(response.getBody()).contains("testuser@example.com"); + + // Verify database was updated + var updatedUser = userJpaRepository.findById(testUser.getId()); + assertThat(updatedUser).isPresent(); + assertThat(updatedUser.get().getName()).isEqualTo("Updated Name"); + assertThat(updatedUser.get().getEmail()).isEqualTo("testuser@example.com"); + } + + @Test + @DisplayName("PUT /users/me - Success (Update Password Only)") + void updateMyProfile_Success_PasswordOnly() { + // Given + String requestBody = """ + { + "password": "newPassword456" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + String originalPasswordHash = testUser.getPassword(); + + // When + var response = restTemplate.exchange("/users/me", HttpMethod.PUT, request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Verify password was updated and encrypted + var updatedUser = userJpaRepository.findById(testUser.getId()); + assertThat(updatedUser).isPresent(); + assertThat(updatedUser.get().getPassword()).isNotEqualTo(originalPasswordHash); + assertThat(updatedUser.get().getPassword()).isNotEqualTo("newPassword456"); // Should be encrypted + assertThat(updatedUser.get().getPassword()).startsWith("$2a$"); // BCrypt prefix + + // Verify new password works + assertThat(passwordEncoder.matches("newPassword456", updatedUser.get().getPassword())).isTrue(); + } + + @Test + @DisplayName("PUT /users/me - Success (Update Both Name and Password)") + void updateMyProfile_Success_NameAndPassword() { + // Given + String requestBody = """ + { + "name": "Completely New Name", + "password": "superSecure999" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + // When + var response = restTemplate.exchange("/users/me", HttpMethod.PUT, request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("Completely New Name"); + + // Verify both name and password were updated + var updatedUser = userJpaRepository.findById(testUser.getId()); + assertThat(updatedUser).isPresent(); + assertThat(updatedUser.get().getName()).isEqualTo("Completely New Name"); + assertThat(passwordEncoder.matches("superSecure999", updatedUser.get().getPassword())).isTrue(); + } + + @Test + @DisplayName("PUT /users/me - Success (Empty Body - No Changes)") + void updateMyProfile_Success_EmptyBody() { + // Given + String requestBody = "{}"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + String originalName = testUser.getName(); + String originalPassword = testUser.getPassword(); + + // When + var response = restTemplate.exchange("/users/me", HttpMethod.PUT, request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Verify nothing changed + var unchangedUser = userJpaRepository.findById(testUser.getId()); + assertThat(unchangedUser).isPresent(); + assertThat(unchangedUser.get().getName()).isEqualTo(originalName); + assertThat(unchangedUser.get().getPassword()).isEqualTo(originalPassword); + } + + @Test + @DisplayName("PUT /users/me - Validation Error (Name Too Short)") + void updateMyProfile_ValidationError_ShortName() { + // Given + String requestBody = """ + { + "name": "A" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + // When + var response = restTemplate.exchange("/users/me", HttpMethod.PUT, request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + + // Verify database was not updated + var unchangedUser = userJpaRepository.findById(testUser.getId()); + assertThat(unchangedUser).isPresent(); + assertThat(unchangedUser.get().getName()).isEqualTo("Test User"); + } + + @Test + @DisplayName("PUT /users/me - Validation Error (Name Too Long)") + void updateMyProfile_ValidationError_LongName() { + // Given + String longName = "A".repeat(51); // 51 characters + String requestBody = String.format(""" + { + "name": "%s" + } + """, longName); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + // When + var response = restTemplate.exchange("/users/me", HttpMethod.PUT, request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("PUT /users/me - Validation Error (Password Too Short)") + void updateMyProfile_ValidationError_ShortPassword() { + // Given + String requestBody = """ + { + "password": "short" + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + // When + var response = restTemplate.exchange("/users/me", HttpMethod.PUT, request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + + // Verify password was not changed + var unchangedUser = userJpaRepository.findById(testUser.getId()); + assertThat(unchangedUser).isPresent(); + assertThat(passwordEncoder.matches("password123", unchangedUser.get().getPassword())).isTrue(); + } + + @Test + @DisplayName("PUT /users/me - Validation Error (Password Too Long)") + void updateMyProfile_ValidationError_LongPassword() { + // Given + String longPassword = "A".repeat(101); // 101 characters + String requestBody = String.format(""" + { + "password": "%s" + } + """, longPassword); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(requestBody, headers); + + // When + var response = restTemplate.exchange("/users/me", HttpMethod.PUT, request, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("GET /users/me - Returns correct data structure") + void getMyProfile_CorrectDataStructure() { + // When + var response = restTemplate.getForEntity("/users/me", String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // Check JSON structure + assertThat(response.getBody()).contains("\"id\":"); + assertThat(response.getBody()).contains("\"email\":"); + assertThat(response.getBody()).contains("\"name\":"); + assertThat(response.getBody()).contains("\"createdAt\":"); + // Password should NOT be exposed + assertThat(response.getBody()).doesNotContain("\"password\":"); + assertThat(response.getBody()).doesNotContain("$2a$"); // No BCrypt hash + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..968045f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: bloomtoget-postgres + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_DB: bloomtoget + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./.docker/init-scripts:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + driver: local diff --git a/domain/src/main/java/com/btg/core/application/port/out/user/LoadUserPort.java b/domain/src/main/java/com/btg/core/application/port/out/user/LoadUserPort.java new file mode 100644 index 0000000..589931c --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/user/LoadUserPort.java @@ -0,0 +1,20 @@ +package com.btg.core.application.port.out.user; + +import java.util.Optional; + +public interface LoadUserPort { + + Optional loadByEmail(String email); + + Optional loadById(Long id); + + boolean existsByEmail(String email); + + record User( + Long id, + String email, + String password, + String name, + String createdAt + ) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/out/user/SaveUserPort.java b/domain/src/main/java/com/btg/core/application/port/out/user/SaveUserPort.java new file mode 100644 index 0000000..b3a471f --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/user/SaveUserPort.java @@ -0,0 +1,13 @@ +package com.btg.core.application.port.out.user; + +public interface SaveUserPort { + + User save(String email, String password, String name); + + record User( + Long id, + String email, + String name, + String createdAt + ) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/out/user/UpdateUserPort.java b/domain/src/main/java/com/btg/core/application/port/out/user/UpdateUserPort.java new file mode 100644 index 0000000..af804b5 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/user/UpdateUserPort.java @@ -0,0 +1,13 @@ +package com.btg.core.application.port.out.user; + +public interface UpdateUserPort { + + User update(Long userId, String name, String password); + + record User( + Long id, + String email, + String name, + String createdAt + ) {} +} diff --git a/domain/src/main/java/com/btg/core/application/service/user/GetUserProfileService.java b/domain/src/main/java/com/btg/core/application/service/user/GetUserProfileService.java new file mode 100644 index 0000000..d5f444d --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/user/GetUserProfileService.java @@ -0,0 +1,28 @@ +package com.btg.core.application.service.user; + +import com.btg.core.application.port.in.user.GetUserProfileUseCase; +import com.btg.core.application.port.out.user.LoadUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetUserProfileService implements GetUserProfileUseCase { + + private final LoadUserPort loadUserPort; + + @Override + public UserProfileResult getUserProfile(Long userId) { + LoadUserPort.User user = loadUserPort.loadById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + userId)); + + return new UserProfileResult( + user.id(), + user.email(), + user.name(), + user.createdAt() + ); + } +} diff --git a/domain/src/main/java/com/btg/core/application/service/user/UpdateUserProfileService.java b/domain/src/main/java/com/btg/core/application/service/user/UpdateUserProfileService.java new file mode 100644 index 0000000..16f2f98 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/user/UpdateUserProfileService.java @@ -0,0 +1,39 @@ +package com.btg.core.application.service.user; + +import com.btg.core.application.port.in.user.UpdateUserProfileUseCase; +import com.btg.core.application.port.out.auth.EncodePasswordPort; +import com.btg.core.application.port.out.user.UpdateUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class UpdateUserProfileService implements UpdateUserProfileUseCase { + + private final UpdateUserPort updateUserPort; + private final EncodePasswordPort encodePasswordPort; + + @Override + public UserProfileResult updateUserProfile(UpdateUserProfileCommand command) { + + String encodedPassword = null; + if (command.password() != null && !command.password().isBlank()) { + encodedPassword = encodePasswordPort.encode(command.password()); + } + + UpdateUserPort.User updatedUser = updateUserPort.update( + command.userId(), + command.name(), + encodedPassword + ); + + return new UserProfileResult( + updatedUser.id(), + updatedUser.email(), + updatedUser.name(), + updatedUser.createdAt() + ); + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/user/adapter/UserPersistenceAdapter.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/user/adapter/UserPersistenceAdapter.java new file mode 100644 index 0000000..298140f --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/user/adapter/UserPersistenceAdapter.java @@ -0,0 +1,80 @@ +package com.btg.infrastructure.persistence.user.adapter; + +import com.btg.core.application.port.out.user.LoadUserPort; +import com.btg.core.application.port.out.user.SaveUserPort; +import com.btg.core.application.port.out.user.UpdateUserPort; +import com.btg.infrastructure.persistence.user.entity.UserJpaEntity; +import com.btg.infrastructure.persistence.user.repository.UserJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.format.DateTimeFormatter; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class UserPersistenceAdapter implements LoadUserPort, SaveUserPort, UpdateUserPort { + + private final UserJpaRepository userJpaRepository; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + @Override + public Optional loadByEmail(String email) { + return userJpaRepository.findByEmail(email) + .map(this::toLoadUser); + } + + @Override + public Optional loadById(Long id) { + return userJpaRepository.findById(id) + .map(this::toLoadUser); + } + + @Override + public boolean existsByEmail(String email) { + return userJpaRepository.existsByEmail(email); + } + + @Override + public SaveUserPort.User save(String email, String password, String name) { + UserJpaEntity entity = new UserJpaEntity(email, password, name); + UserJpaEntity saved = userJpaRepository.save(entity); + return new SaveUserPort.User( + saved.getId(), + saved.getEmail(), + saved.getName(), + saved.getCreatedAt().format(FORMATTER) + ); + } + + @Override + public UpdateUserPort.User update(Long userId, String name, String password) { + UserJpaEntity entity = userJpaRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + userId)); + + if (name != null && !name.isBlank()) { + entity.updateName(name); + } + if (password != null && !password.isBlank()) { + entity.updatePassword(password); + } + + UserJpaEntity updated = userJpaRepository.save(entity); + return new UpdateUserPort.User( + updated.getId(), + updated.getEmail(), + updated.getName(), + updated.getCreatedAt().format(FORMATTER) + ); + } + + private LoadUserPort.User toLoadUser(UserJpaEntity entity) { + return new LoadUserPort.User( + entity.getId(), + entity.getEmail(), + entity.getPassword(), + entity.getName(), + entity.getCreatedAt().format(FORMATTER) + ); + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/user/entity/UserJpaEntity.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/user/entity/UserJpaEntity.java new file mode 100644 index 0000000..34724af --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/user/entity/UserJpaEntity.java @@ -0,0 +1,59 @@ +package com.btg.infrastructure.persistence.user.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + @Column(nullable = false, length = 255) + private String password; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public UserJpaEntity(String email, String password, String name) { + this.email = email; + this.password = password; + this.name = name; + } + + public void updateName(String name) { + this.name = name; + } + + public void updatePassword(String password) { + this.password = password; + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/user/repository/UserJpaRepository.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/user/repository/UserJpaRepository.java new file mode 100644 index 0000000..3845e96 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/user/repository/UserJpaRepository.java @@ -0,0 +1,15 @@ +package com.btg.infrastructure.persistence.user.repository; + +import com.btg.infrastructure.persistence.user.entity.UserJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserJpaRepository extends JpaRepository { + + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/user/UserController.java b/infrastructure/src/main/java/com/btg/infrastructure/web/user/UserController.java index c77165b..6f97c59 100644 --- a/infrastructure/src/main/java/com/btg/infrastructure/web/user/UserController.java +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/user/UserController.java @@ -19,7 +19,7 @@ public class UserController { @GetMapping("/me") public ResponseEntity getMyProfile() { - // TODO: Get userId from security context (currently hardcoded for testing) + // TODO: userId 받아오기 Long userId = 1L; GetUserProfileUseCase.UserProfileResult result = getUserProfileUseCase.getUserProfile(userId); @@ -36,7 +36,7 @@ public ResponseEntity getMyProfile() { @PutMapping("/me") public ResponseEntity updateMyProfile(@Valid @RequestBody UpdateUserRequest request) { - // TODO: Get userId from security context (currently hardcoded for testing) + // TODO: userId 받아오기 Long userId = 1L; UpdateUserProfileUseCase.UpdateUserProfileCommand command = new UpdateUserProfileUseCase.UpdateUserProfileCommand(