diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..4cd83877 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew test:*)", + "Bash(./gradlew :apps:commerce-api:test:*)" + ] + } +} diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## ๐Ÿงช Implementation Quest - -> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. - -### ํšŒ์› ๊ฐ€์ž… - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [ ] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) -- [ ] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ๋‚ด ์ •๋ณด ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e57d39ca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,166 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Tech Stack & Versions + +| Category | Technology | Version | +|----------|------------|---------| +| Language | Java | 21 | +| Framework | Spring Boot | 3.4.4 | +| Dependency Management | Spring Dependency Management | 1.1.7 | +| Cloud | Spring Cloud | 2024.0.1 | +| Build Tool | Gradle (Kotlin DSL) | 8.13+ | +| API Documentation | SpringDoc OpenAPI | 2.7.0 | +| ORM | Spring Data JPA + QueryDSL | (managed by Spring Boot) | +| Database | MySQL | 8.0 | +| Cache | Redis (Master-Replica) | - | +| Messaging | Kafka | 3.5.1 | +| Monitoring | Micrometer + Prometheus | (managed by Spring Boot) | +| Logging | Logback + Slack Appender | 1.6.1 | +| Testing | JUnit 5, Mockito 5.14.0, SpringMockk 4.0.2, Instancio 5.0.2 | - | +| Containers | TestContainers | (managed by Spring Boot) | + +## Build & Run Commands + +```bash +# Build all modules +./gradlew build + +# Run tests (profile: test, timezone: Asia/Seoul) +./gradlew test + +# Run specific app +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun --args='--job.name=jobName' +./gradlew :apps:commerce-streamer:bootRun + +# Build specific module +./gradlew :apps:commerce-api:build + +# Run single test class +./gradlew test --tests "com.loopers.ExampleServiceIntegrationTest" + +# Run single test method +./gradlew test --tests "com.loopers.ExampleServiceIntegrationTest.testMethodName" + +# Test with coverage report +./gradlew test jacocoTestReport +``` + +**Java version**: 21 (configured via Gradle toolchain) + +## Local Infrastructure + +```bash +# Start MySQL, Redis (master+replica), Kafka +docker-compose -f docker/infra-compose.yml up + +# Start Prometheus + Grafana monitoring +docker-compose -f docker/monitoring-compose.yml up +``` + +- MySQL: localhost:3307 (root/root, application/application) +- Redis Master: localhost:6379, Replica: localhost:6380 +- Kafka: localhost:19092, Kafka UI: localhost:9099 +- Grafana: localhost:3000 (admin/admin) + +## Architecture + +### Multi-Module Structure + +``` +loopers-java-spring-template/ +โ”œโ”€โ”€ apps/ # Executable Spring Boot applications +โ”‚ โ”œโ”€โ”€ commerce-api # REST API (web, actuator, springdoc-openapi) +โ”‚ โ”œโ”€โ”€ commerce-batch # Batch jobs (spring-batch) +โ”‚ โ””โ”€โ”€ commerce-streamer # Event streaming (web, kafka) +โ”œโ”€โ”€ modules/ # Reusable infrastructure configurations +โ”‚ โ”œโ”€โ”€ jpa # JPA, QueryDSL, MySQL connector +โ”‚ โ”œโ”€โ”€ redis # Spring Data Redis (master-replica) +โ”‚ โ””โ”€โ”€ kafka # Spring Kafka +โ””โ”€โ”€ supports/ # Cross-cutting add-on modules + โ”œโ”€โ”€ jackson # Jackson serialization (Kotlin module, JSR310) + โ”œโ”€โ”€ logging # Logback, Slack appender + โ””โ”€โ”€ monitoring # Micrometer, Prometheus registry +``` + +### Module Dependencies + +| App | modules | supports | +|-----|---------|----------| +| commerce-api | jpa, redis | jackson, logging, monitoring | +| commerce-batch | jpa, redis | jackson, logging, monitoring | +| commerce-streamer | jpa, redis, kafka | jackson, logging, monitoring | + +### Layer Architecture (commerce-api) +``` +interfaces/api/ โ†’ Controllers, DTOs, OpenAPI specs +application/ โ†’ Facades (use case orchestration) +domain/ โ†’ Entities, Services, Repository interfaces +infrastructure/ โ†’ Repository implementations, adapters +``` + +### Key Patterns +- **Controllers**: Implement `*ApiSpec` interfaces for OpenAPI documentation +- **Facades**: Orchestrate domain services, convert domain models to DTOs +- **Services**: `@Component` with `@Transactional`, contain business logic +- **Repositories**: Interface in `domain/`, implementation in `infrastructure/` +- **Entities**: Extend `BaseEntity` (provides id, createdAt, updatedAt, deletedAt) +- **Response wrapper**: All APIs return `ApiResponse` +- **Error handling**: `CoreException` with `ErrorType` enum, caught by `ApiControllerAdvice` + +### Soft Delete +Entities use `deletedAt` field via `BaseEntity`: +```java +entity.delete(); // marks as deleted +entity.restore(); // restores +``` + +## Configuration + +- Profile-based: local, test, dev, qa, prd +- Config imports in application.yml: jpa.yml, redis.yml, logging.yml, monitoring.yml +- Management endpoints on port 8081 (/health, /prometheus) + +## Testing + +- Framework: JUnit 5 + AssertJ + Mockito + SpringMockk + Instancio +- `DatabaseCleanUp` utility truncates tables between tests (from jpa test fixtures) +- `RedisCleanUp` available from redis test fixtures +- TestContainers support for MySQL, Redis, Kafka + +## ๊ฐœ๋ฐœ ๊ทœ์น™ + +### ์ง„ํ–‰ Workflow - ์ฆ๊ฐ• ์ฝ”๋”ฉ +- **๋Œ€์›์น™**: ๋ฐฉํ–ฅ์„ฑ ๋ฐ ์ฃผ์š” ์˜์‚ฌ ๊ฒฐ์ •์€ ๊ฐœ๋ฐœ์ž์—๊ฒŒ ์ œ์•ˆ๋งŒ ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ตœ์ข… ์Šน์ธ๋œ ์‚ฌํ•ญ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž‘์—… ์ˆ˜ํ–‰ +- **์ค‘๊ฐ„ ๊ฒฐ๊ณผ ๋ณด๊ณ **: AI๊ฐ€ ๋ฐ˜๋ณต์ ์ธ ๋™์ž‘์„ ํ•˜๊ฑฐ๋‚˜, ์š”์ฒญํ•˜์ง€ ์•Š์€ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„, ํ…Œ์ŠคํŠธ ์‚ญ์ œ๋ฅผ ์ž„์˜๋กœ ์ง„ํ–‰ํ•  ๊ฒฝ์šฐ ๊ฐœ๋ฐœ์ž๊ฐ€ ๊ฐœ์ž… +- **์„ค๊ณ„ ์ฃผ๋„๊ถŒ ์œ ์ง€**: AI๊ฐ€ ์ž„์˜ํŒ๋‹จ์„ ํ•˜์ง€ ์•Š๊ณ , ๋ฐฉํ–ฅ์„ฑ์— ๋Œ€ํ•œ ์ œ์•ˆ ๋“ฑ์„ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ์œผ๋‚˜ ๊ฐœ๋ฐœ์ž์˜ ์Šน์ธ์„ ๋ฐ›์€ ํ›„ ์ˆ˜ํ–‰ + +### ๊ฐœ๋ฐœ Workflow - TDD (Red โ†’ Green โ†’ Refactor) +- ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋Š” 3A ์›์น™์œผ๋กœ ์ž‘์„ฑ (Arrange - Act - Assert) + +| Phase | ์„ค๋ช… | +|-------|------| +| **Red** | ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑํ•˜๋Š” ์‹คํŒจํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ๋จผ์ € ์ž‘์„ฑ | +| **Green** | Red Phase์˜ ํ…Œ์ŠคํŠธ๊ฐ€ ๋ชจ๋‘ ํ†ต๊ณผํ•  ์ˆ˜ ์žˆ๋Š” ์ตœ์†Œํ•œ์˜ ์ฝ”๋“œ ์ž‘์„ฑ (์˜ค๋ฒ„์—”์ง€๋‹ˆ์–ด๋ง ๊ธˆ์ง€) | +| **Refactor** | ๋ถˆํ•„์š”ํ•œ private ํ•จ์ˆ˜ ์ง€์–‘, ๊ฐ์ฒด์ง€ํ–ฅ์  ์ฝ”๋“œ ์ž‘์„ฑ, unused import ์ œ๊ฑฐ, ์„ฑ๋Šฅ ์ตœ์ ํ™”. ๋ชจ๋“  ํ…Œ์ŠคํŠธ ํ†ต๊ณผ ํ•„์ˆ˜ | + +### ์ฃผ์˜์‚ฌํ•ญ + +**Never Do:** +- ์‹ค์ œ ๋™์ž‘ํ•˜์ง€ ์•Š๋Š” ์ฝ”๋“œ, ๋ถˆํ•„์š”ํ•œ Mock ๋ฐ์ดํ„ฐ๋ฅผ ์ด์šฉํ•œ ๊ตฌํ˜„ ๊ธˆ์ง€ +- null-safety ํ•˜์ง€ ์•Š์€ ์ฝ”๋“œ ์ž‘์„ฑ ๊ธˆ์ง€ (Java์˜ ๊ฒฝ์šฐ Optional ํ™œ์šฉ) +- println ์ฝ”๋“œ ๋‚จ๊ธฐ์ง€ ๋ง ๊ฒƒ + +**Recommendation:** +- ์‹ค์ œ API๋ฅผ ํ˜ธ์ถœํ•ด ํ™•์ธํ•˜๋Š” E2E ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ +- ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฐ์ฒด ์„ค๊ณ„ +- ์„ฑ๋Šฅ ์ตœ์ ํ™”์— ๋Œ€ํ•œ ๋Œ€์•ˆ ๋ฐ ์ œ์•ˆ +- ๊ฐœ๋ฐœ ์™„๋ฃŒ๋œ API๋Š” `http/*.http` ํŒŒ์ผ์— ๋ถ„๋ฅ˜ํ•ด ์ž‘์„ฑ + +**Priority:** +1. ์‹ค์ œ ๋™์ž‘ํ•˜๋Š” ํ•ด๊ฒฐ์ฑ…๋งŒ ๊ณ ๋ ค +2. null-safety, thread-safety ๊ณ ๋ ค +3. ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ์„ค๊ณ„ +4. ๊ธฐ์กด ์ฝ”๋“œ ํŒจํ„ด ๋ถ„์„ ํ›„ ์ผ๊ด€์„ฑ ์œ ์ง€ diff --git a/MERMAID.md b/MERMAID.md new file mode 100644 index 00000000..09f77cf3 --- /dev/null +++ b/MERMAID.md @@ -0,0 +1,167 @@ +# Flow Diagrams + +## 1. ํšŒ์›๊ฐ€์ž… (POST /api/v1/members) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: POST /api/v1/members (SignUpRequest) + Controller->>Facade: signupMember(request) + Facade->>Facade: Request to MemberModel ๋ณ€ํ™˜ + Facade->>Service: saveMember(memberModel) + Service->>DB: findByLoginId (์ค‘๋ณต ์ฒดํฌ) + DB-->>Service: Optional.empty() + Service->>Service: passwordEncoder.encode() + Service->>DB: save(memberModel) + DB-->>Service: savedMember + Service-->>Facade: MemberModel + Facade->>Facade: MemberModel to MemberInfo ๋ณ€ํ™˜ + Facade-->>Controller: MemberInfo + Controller-->>Client: 201 Created (SignUpResponse) +``` + +### ์˜ˆ์™ธ ํ๋ฆ„ + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: POST /api/v1/members (์ค‘๋ณต ID) + Controller->>Facade: signupMember(request) + Facade->>Service: saveMember(memberModel) + Service->>DB: findByLoginId + DB-->>Service: Optional.of(existingMember) + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict +``` + +--- + +## 2. ๋‚ด ์ •๋ณด ์กฐํšŒ (GET /api/v1/members/me) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: GET /api/v1/members/me + Note over Client,Controller: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Controller->>Facade: getMyInfo(loginId, password) + Facade->>Service: authenticate(loginId, password) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches() + Service-->>Facade: MemberModel (์ธ์ฆ ์„ฑ๊ณต) + Facade->>Facade: MemberModel to MemberInfo ๋ณ€ํ™˜ (์ด๋ฆ„ ๋งˆ์Šคํ‚น) + Facade-->>Controller: MemberInfo + Controller-->>Client: 200 OK (MemberInfoResponse) +``` + +### ์˜ˆ์™ธ ํ๋ฆ„ - ์ธ์ฆ ์‹คํŒจ + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: GET /api/v1/members/me (ํ‹€๋ฆฐ ๋น„๋ฐ€๋ฒˆํ˜ธ) + Controller->>Facade: getMyInfo(loginId, wrongPassword) + Facade->>Service: authenticate(loginId, wrongPassword) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches() = false + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized +``` + +--- + +## 3. ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ (PATCH /api/v1/members/me/password) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: PATCH /api/v1/members/me/password + Note over Client,Controller: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Note over Client,Controller: Body: oldPassword, newPassword + Controller->>Facade: changePassword(loginId, headerPw, oldPw, newPw) + Facade->>Service: authenticate(loginId, headerPw) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches(headerPw) + Service-->>Facade: ์ธ์ฆ ์„ฑ๊ณต + Facade->>Facade: new MemberModel(loginId, oldPw) + Facade->>Service: changePassword(memberModel, newPw) + Service->>Service: passwordEncoder.matches(oldPw) ๊ฒ€์ฆ + Service->>Service: newPw != oldPw ๊ฒ€์ฆ + Service->>Service: validatePassword(newPw) ๊ทœ์น™ ๊ฒ€์ฆ + Service->>Service: passwordEncoder.encode(newPw) + Service->>DB: Dirty Checking (์ž๋™ ์ €์žฅ) + Service-->>Facade: void + Facade-->>Controller: void + Controller-->>Client: 200 OK +``` + +### ์˜ˆ์™ธ ํ๋ฆ„ - Body์˜ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜ + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + + Client->>Controller: PATCH (ํ—ค๋” ์ธ์ฆ OK, Body oldPw ํ‹€๋ฆผ) + Controller->>Facade: changePassword(...) + Facade->>Service: authenticate() ์„ฑ๊ณต + Facade->>Service: changePassword(wrongOldPw, newPw) + Service->>Service: passwordEncoder.matches(wrongOldPw) = false + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized +``` + +### ์˜ˆ์™ธ ํ๋ฆ„ - ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ธฐ์กด๊ณผ ๋™์ผ + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + + Client->>Controller: PATCH (newPw == oldPw) + Controller->>Facade: changePassword(...) + Facade->>Service: authenticate() ์„ฑ๊ณต + Facade->>Service: changePassword(oldPw, samePassword) + Service->>Service: oldPw ๊ฒ€์ฆ ์„ฑ๊ณต + Service->>Service: newPw == oldPw ์ฒดํฌ + Service-->>Controller: CoreException (BAD_REQUEST) + Controller-->>Client: 400 Bad Request +``` \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 00000000..781bacce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,49 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.member.MemberV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberService memberService; + + public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { + // 1. Request โ†’ MemberModel๋กœ ๋ณ€ํ™˜ + MemberModel memberModel = new MemberModel( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + + // 2. Service ํ˜ธ์ถœ (์ €์žฅ + ์ค‘๋ณต ์ฒดํฌ) + MemberModel saved = memberService.saveMember(memberModel); + + // 3. MemberModel โ†’ MemberInfo๋กœ ๋ณ€ํ™˜ํ•ด์„œ ๋ฐ˜ํ™˜ + return MemberInfo.from(saved); + } + + + public MemberInfo getMyInfo(String loginId, String password) { + MemberModel member = memberService.authenticate(loginId, password); + return MemberInfo.from(member); + } + + + public void changePassword(String loginId, String password, String prevPassword, String newPassword) { + // ํ—ค๋” ์ธ์ฆ + memberService.authenticate(loginId, password); + + MemberModel memberModel = new MemberModel(loginId, prevPassword); // raw prevPassword + + // Service ํ˜ธ์ถœ + memberService.changePassword(memberModel, newPassword); + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 00000000..38b99890 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,15 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + +// MemberInfo๋Š” Facade โ†’ Controller๋กœ ์ „๋‹ฌ๋˜๋Š” ๋ฐ์ดํ„ฐ +public record MemberInfo(String loginId, String name, String birthDate, String email) { + public static MemberInfo from(MemberModel model) { + return new MemberInfo( + model.getLoginId(), + model.getMaskedName(), + model.getBirthDate(), + model.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java new file mode 100644 index 00000000..7e23f737 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java new file mode 100644 index 00000000..58e72de9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -0,0 +1,148 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + private String loginId; + private String password; + private String name; + private String birthDate; + private String email; + + protected MemberModel() { + } + + public MemberModel(String loginId, String password, String name, String birthDate, String email) { + + // ๋ชจ๋“  ํ•ญ๋ชฉ์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๋‹ค + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์•„์ด๋””๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + // ๊ฐ€์ž…๋œ ์•„์ด๋””๋กœ๋Š” ๊ฐ€์ž…์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค -> ๋””๋น„์—์„œ ๊ฒ€์ฆ. ์„œ๋น„์Šค์—์„œ ํ•˜๊ธฐ + // ๊ฐ€์ž…ํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉํ•œ๋‹ค + validateLoginId(loginId); + + // ๋น„๋ฐ€๋ฒˆํ˜ธ 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + // ๋น„๋ฐ€๋ฒˆํ˜ธ ์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + // ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ + validatePassword(password, birthDate); + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public MemberModel(String loginId) { + // ๋ชจ๋“  ํ•ญ๋ชฉ์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๋‹ค + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์•„์ด๋””๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + // ๊ฐ€์ž…ํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉํ•œ๋‹ค + validateLoginId(loginId); + this.loginId = loginId; + } + + public MemberModel(String loginId, String prevPassword) { + this.loginId = loginId; + this.password = prevPassword; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } + + private void validateLoginId(String loginId) { + // ๋กœ๊ทธ์ธ ID ๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉ + if (!loginId.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ์•„์ด๋””๋Š” ์˜๋ฌธ์ž์™€ ์ˆซ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + } + + private void validatePassword(String password, String birthDate) { + // 1. 8~16์ž ๊ธธ์ด ์ฒดํฌ + if (password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~16์ž์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + // 2. ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ํ—ˆ์šฉ (ํ•œ๊ธ€, ๊ณต๋ฐฑ ๋“ฑ ๋ถˆ๊ฐ€) + if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); + } + + // 3. ์ƒ๋…„์›”์ผ์ด ๋น„๋ฐ€๋ฒˆํ˜ธ์— ํฌํ•จ๋˜๋ฉด ์•ˆ๋จ + if (password.contains(birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์„ ํฌํ•จํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + // ์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์—”ํ‹ฐํ‹ฐ์— ๋„ฃ์–ด์ฃผ๊ธฐ + public void encryptPassword(String encryptedPassword) { + this.password = encryptedPassword; + } + + // ์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž์— ๋งˆ์Šคํ‚น ์ถ”๊ฐ€ + public String maskLastChar(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("์ด๋ฆ„์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if (name.length() == 1) { + return "*"; + } + + return name.substring(0, name.length() - 1) + "*"; + } + + // ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„ ๊ฐ€์ ธ์˜ค๊ธฐ + public String getMaskedName() { + return maskLastChar(this.name); + } + + + // ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝํ•˜๊ธฐ + public void changePassword(String newPassword, String birthDate) { + validatePassword(newPassword, birthDate); + this.password = newPassword; + } + + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 00000000..ca1e3cd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + MemberModel save(MemberModel memberModel); + Optional findByLoginId(String id); + Optional update(MemberModel memberModel); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 00000000..0ab66665 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,82 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = false) + public MemberModel saveMember(MemberModel memberModel) { + //์ €์žฅํ•˜๊ธฐ ์ „์— ์ด๋ฏธ ๊ฐ™์€ loginId๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + Optional existing = memberRepository.findByLoginId(memberModel.getLoginId()); + if (existing.isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); + } + + // ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™” ํ›„ ์ €์žฅ + String encrypted = passwordEncoder.encode(memberModel.getPassword()); + memberModel.encryptPassword(encrypted); + + try { + return memberRepository.save(memberModel); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); + } + } + + public MemberModel authenticate(String loginId, String password) { + // 1. ํšŒ์› ์กฐํšŒ + MemberModel member = getMember(loginId); // ์—†์œผ๋ฉด NOT_FOUND + + // 2. ๋น„๋ฐ€๋ฒˆํ˜ธ ์ผ์น˜ ์—ฌ๋ถ€ ํ™•์ธ + if (!passwordEncoder.matches(password, member.getPassword())) { + // 3. ๋ถˆ์ผ์น˜ ์‹œ UNAUTHORIZED ์˜ˆ์™ธ + throw new CoreException(ErrorType.UNAUTHORIZED, "์ธ์ฆ ์‹คํŒจ"); + } + + return member; + } + + @Transactional(readOnly = true) + public MemberModel getMember(String loginId) { + MemberModel model = new MemberModel(loginId); // ๊ฐ์ฒด ๋จผ์ € ์ƒ์„ฑํ•ด์•ผ ํ•จ + return memberRepository.findByLoginId(model.getLoginId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + + @Transactional(readOnly = false) + public void changePassword(MemberModel memberModel, String newPassword) { + + // ๊ธฐ์กด ํšŒ์› ์ •๋ณด ์กฐํšŒ + MemberModel member = getMember(memberModel.getLoginId()); + + // ์•”ํ˜ธํ™”๋œ DB ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ž…๋ ฅํ•œ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต + if (!passwordEncoder.matches(memberModel.getPassword(), member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + // ์•”ํ˜ธํ™”๋œ DB ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ž…๋ ฅํ•œ ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต + if (passwordEncoder.matches(newPassword, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋‹ฌ๋ผ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + // ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ + ์•”ํ˜ธํ™” + ์ €์žฅ (Dirty Checking) + member.changePassword(newPassword, member.getBirthDate()); + + // ์•”ํ˜ธํ™” ํ›„ ์ €์žฅ (Dirty Checking) + String encryptedPassword = passwordEncoder.encode(newPassword); + member.encryptPassword(encryptedPassword); + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 00000000..0bcc2c78 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + MemberModel save(MemberModel memberModel); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 00000000..17e86c5d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public MemberModel save(MemberModel memberModel) { + return memberJpaRepository.save(memberModel); + } + + @Override + public Optional update(MemberModel memberModel) { + return Optional.empty(); + } + + @Override + public Optional findByLoginId(String id) { + return memberJpaRepository.findByLoginId(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 00000000..52cb6480 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Member V1 API", description = "ํšŒ์› API") +public interface MemberV1ApiSpec { + + @Operation( + summary = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", + description = "์ฃผ์–ด์ง„ ์ •๋ณด๋ฅผ ๊ฐ€์ง€๊ณ  ํšŒ์› ๊ฐ€์ž…์„ ์‹คํ–‰ํ•œ๋‹ค" + ) + // @Schema๋Š” Swagger API ๋ฌธ์„œ์—์„œ ํŒŒ๋ผ๋ฏธํ„ฐ ์„ค๋ช…์„ ๋ณด์—ฌ์ฃผ๋Š” ์šฉ๋„ + // ์˜ˆ์ œ์—์„œ๋Š” Long exampleId ๊ฐ™์€ ๋‹จ์ผ ํŒŒ๋ผ๋ฏธํ„ฐ์— ๋ถ™์˜€๋Š”๋ฐ, ์ง€๊ธˆ์€ SignUpRequest๋กœ ํ†ต์งธ๋กœ ๋ฐ›์œผ๋‹ˆ๊นŒ ์—ฌ๊ธฐ์—” ํ•„์š” ์—†์Œ + ApiResponse signUp( + MemberV1Dto.SignUpRequest request + ); + + @Operation( + summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", + description = "๋กœ๊ทธ์ธ ID๋กœ ๋‚ด ํšŒ์› ์ •๋ณด๋ฅผ ์กฐํšŒํ•œ๋‹ค" + ) + ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ); + + @Operation( + summary = "๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ", + description = "๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ฐ›์•„ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•œ๋‹ค" + ) + ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + MemberV1Dto.ChangePasswordRequest request + ); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 00000000..07bf218e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.ChangePasswordRequest; +import com.loopers.interfaces.api.member.MemberV1Dto.MemberInfoResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.SignUpRequest; +import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + // ํด๋ผ์ด์–ธํŠธ โ†’ [SignUpRequest (record)] โ†’ Controller โ†’ Facade โ†’ Service โ†’ [MemberModel (entity)] โ†’ DB + // ์š”์ฒญ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ์šฉ DB์— ์ €์žฅ๋˜๋Š” ๊ฐ์ฒด + // DB โ†’ [MemberModel (entity)] โ†’ Facade โ†’ [SignUpResponse (record)] โ†’ Controller โ†’ ํด๋ผ์ด์–ธํŠธ + // DB์—์„œ ๊บผ๋‚ธ ๊ฐ์ฒด ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ „๋‹ฌ์šฉ + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp(@RequestBody SignUpRequest request) { + MemberInfo info = memberFacade.signupMember(request); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo info = memberFacade.getMyInfo(loginId, password); + MemberInfoResponse response = MemberInfoResponse.from(info); + return ApiResponse.success(response); + } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody ChangePasswordRequest request + ) { + memberFacade.changePassword(loginId, password, request.oldPassword(), request.newPassword()); + return ApiResponse.success("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 00000000..e4e6f046 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.member; + + +import com.loopers.application.member.MemberInfo; + +public class MemberV1Dto { + + // Request: POST๋ฐฉ์‹์œผ๋กœ ๋ณด๋‚ผ๋•Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ด๋Š” ๊ทธ๋ฆ‡ (from ํ•„์š” ์—†์Œ) + public record SignUpRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + // Response: ๋ณ€ํ™˜ ๋ฉ”์„œ๋“œ(from)๊ฐ€ ์—ฌ๊ธฐ์—! + public record SignUpResponse(String loginId) { + public static SignUpResponse from(MemberInfo info) { + return new SignUpResponse(info.loginId()); + } + } + + public record MemberInfoResponse( + String loginId, + String name, + String birthDate, + String email + ) { + public static MemberInfoResponse from(MemberInfo info) { + return new MemberInfoResponse( + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ); + } + } + + + // Request: ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ + public record ChangePasswordRequest( + String oldPassword, + String newPassword + ) {} + + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..96c81c9f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -11,7 +11,8 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "์ผ์‹œ์ ์ธ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "์œ ํšจํ•˜์ง€ ์•Š์€ ์ธ์ฆ ์ •๋ณด์ž…๋‹ˆ๋‹ค."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java new file mode 100644 index 00000000..e28f0cda --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -0,0 +1,220 @@ +package com.loopers.domain.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class MemberModelTest { + + @DisplayName("ํšŒ์› ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + + @DisplayName("(์„ฑ๊ณต์ผ€์ด์Šค) ํ•„์ˆ˜ ์ •๋ณด๊ฐ€ ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsMemberModel_whenAllFieldsAreProvided() { + // arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + // assert + assertThat(member.getLoginId()).isEqualTo(loginId); + assertThat(member.getPassword()).isEqualTo(rawPassword); + assertThat(member.getName()).isEqualTo(name); + assertThat(member.getBirthDate()).isEqualTo(birthDate); + assertThat(member.getEmail()).isEqualTo(email); + // ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์•”ํ˜ธํ™”๋˜์–ด ์ €์žฅ๋˜๋ฏ€๋กœ ์›๋ณธ๊ณผ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ - ๋‚˜์ค‘์— ๊ฒ€์ฆ ๋ฐฉ์‹ ๊ฒฐ์ • + } + + @DisplayName("์•„์ด๋””๋กœ ํšŒ์› ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ์˜๋ฌธ๊ณผ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenLoginIdContainsInvalidChars() { + // arrange + String loginId = "testuser!@#"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId); + }); + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("(์‹คํŒจ์ผ€์ด์Šค) ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 7์ž์ผ๋•Œ, ์˜ˆ์™ธ๋ฐœ์ƒ.") + @Test + void throwsBadRequestException_whenPwIsOutOfRange() { + // arrange + String loginId = "testuser"; + String password = "Test12!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 17์ž์ผ ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void throwsBadRequestException_whenPwIsOutOfRange2() { + // arrange + String loginId = "testuser"; + String password = "Test123456789012!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ํ•œ๊ธ€์ด ์žˆ์„ ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void throwsBadRequestException_whenPwIsKorean() { + // arrange + String loginId = "testuser"; + String password = "Testํ™๊ธธ๋™890123!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋  ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void throwsBadRequestException_whenPwContainsBirthDate() { + // arrange + String loginId = "testuser"; + String password = "Test19900101!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("ํšŒ์›์ •๋ณด์กฐํšŒํ•  ๋•Œ,") + @Nested + class GetMemberInfo { + + @DisplayName("์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค") + @Test + void mask_last_character() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("ํ™๊ธธ*"); + } + + @DisplayName("์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค") + @Test + void single_character_name_is_fully_masked() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "ํ™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("*"); + } + + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆ˜์ • ํ•  ๋•Œ,") + @Nested + class ChangePassword { + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์„ ๋งŒ์กฑํ•˜๋ฉด, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค.") + @Test + void changesPassword_whenOldPasswordMatchesAndNewPasswordIsValid() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + // act + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + String newPassword = "Newpass123!"; + member.changePassword(newPassword, birthDate); + + // assert + assertThat(member.getPassword()).isEqualTo(newPassword); + } + + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenNewPasswordContainsBirthDate() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + String newPassword = "Test19900101!"; + + // act + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + member.changePassword(newPassword, birthDate); + }); + + // assert - ErrorType.BAD_REQUEST + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java new file mode 100644 index 00000000..90820170 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -0,0 +1,188 @@ +package com.loopers.domain.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class MemberServiceIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํšŒ์›๊ฐ€์ž…์„ ์„ฑ๊ณตํ•œ๋‹ค") + @Nested + class SaveMember { + + @DisplayName("ํšŒ์›๊ฐ€์ž…์— ํ•„์š”ํ•œ ์ •๋ณด๊ฐ€ ๋“ค์–ด์˜ค๋ฉด ๋””๋น„์— ์ €์žฅํ•˜๊ณ  ์ €์žฅํ•œ ์•„์ด๋””๋ฅผ ์กฐํšŒํ•œ๋‹ค") + @Test + void returnsMemberInfo_whenValidMemberInfoIsProvided() { + // arrange + MemberModel memberModel = new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + memberService.saveMember(memberModel); + MemberModel result = memberService.getMember(memberModel.getLoginId()); + + // assert + assertAll(() -> assertThat(result).isNotNull(), () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail())); + } + + @DisplayName("์ค‘๋ณต ID๋กœ ๊ฐ€์ž… ์‹œ๋„ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsException_whenExistIdIsTryToSaveMember() { + // arrange - ๋จผ์ € ํ•œ ๋ช… ๊ฐ€์ž…์‹œํ‚ค๊ธฐ + memberService.saveMember(new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + + // act - ๊ฐ™์€ ID๋กœ ๋˜ ๊ฐ€์ž… ์‹œ๋„ + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.saveMember(new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + } + + @DisplayName("ํšŒ์›์„ ์กฐํšŒํ•  ๋•Œ, ") + @Nested + class GetMember { + + @DisplayName("์กด์žฌํ•˜๋Š” ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange // ์ •๋ณด์ €์žฅ + MemberModel memberModel = new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + memberService.saveMember(memberModel); + + // act + MemberModel result = memberService.getMember(memberModel.getLoginId()); + + // assert + assertAll(() -> assertThat(result).isNotNull(), () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail())); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsException_whenMemberNotFound() { + // arrange + String loginId = "testuser"; // Assuming this ID does not exist + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.getMember(loginId); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ์„ ํ•  ๋•Œ, ") + @Nested + class ChangePassword { + + @DisplayName("๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์œ ํšจํ•˜๋ฉด, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค.") + @Test + void changesPassword_whenOldAndNewPasswordsAreValid() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + // act + // ๊ธฐ์กด ๋“ฑ๋ก๋œ ๋””๋น„ ์„ค์ • + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + memberService.saveMember(member); + + // ํด๋ผ์—์„œ ์ž…๋ ฅํ•œ ์•„์ด๋””์™€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ + MemberModel insertedMember = new MemberModel(loginId, prevPassword); + String newPassword = "NewPass123!"; + + // act + memberService.changePassword(insertedMember, newPassword); + + // assert + MemberModel updatedMember = memberService.getMember("testuser"); + // ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ (์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต) + assertThat(updatedMember.getPassword()).isNotEqualTo(insertedMember.getPassword()); + } + + @DisplayName("๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenOldPasswordDoesNotMatch() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + // act + // ๊ธฐ์กด ๋“ฑ๋ก๋œ ๋””๋น„ ์„ค์ • + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + memberService.saveMember(member); + + // ํด๋ผ์—์„œ ์ž…๋ ฅํ•œ ์•„์ด๋””์™€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ + String wrongPrevPassword = "WrongPass!"; + MemberModel insertedMember = new MemberModel(loginId, wrongPrevPassword); + String newPassword = "NewPass123!"; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.changePassword(insertedMember, newPassword); + }); + + // assert - ErrorType.UNAUTHORIZED + assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๊ฐ™์œผ๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenNewPasswordIsSameAsOld() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + memberService.saveMember(member); + + // ํด๋ผ์—์„œ ์ž…๋ ฅํ•œ ์•„์ด๋””์™€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ + MemberModel insertedMember = new MemberModel(loginId, prevPassword); + String newPassword = "Test1234!"; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.changePassword(insertedMember, newPassword); + }); + + // assert - ErrorType.UNAUTHORIZED ๋˜๋Š” BAD_REQUEST + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java new file mode 100644 index 00000000..b9fb6e26 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,355 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/members"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members (ํšŒ์›๊ฐ€์ž…)") + @Nested + class SignUp { + + @DisplayName("์œ ํšจํ•œ ํšŒ์› ์ •๋ณด๋ฅผ ๋ณด๋‚ด๋ฉด, 201 Created์™€ ์ƒ์„ฑ๋œ ID๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsCreated_whenValidMemberInfoIsProvided() { + // arrange + Map request = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@example.com" + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isNotNull(); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("loginId")).isNotNull(); + } + ); + } + + @DisplayName("์ค‘๋ณต๋œ loginId๋กœ ๊ฐ€์ž…ํ•˜๋ฉด, 409 Conflict๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsConflict_whenDuplicateLoginIdIsProvided() { + // arrange - ๋จผ์ € ํ•œ ๋ช… ๊ฐ€์ž… + Map request = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@example.com" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference>>() {}); + + // act - ๊ฐ™์€ ID๋กœ ๋‹ค์‹œ ๊ฐ€์ž… + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } + + @DisplayName("GET /api/v1/members/me (ํšŒ์›์ •๋ณด์กฐํšŒ)") + @Nested + class GetMemberInfo { + + @DisplayName("ํ—ค๋” ์ธ์ฆ ์„ฑ๊ณต ์‹œ, 200 OK์™€ ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsMemberInfo_whenHeaderAuthIsValid() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ํ—ค๋”์— ์ธ์ฆ ์ •๋ณด ํฌํ•จํ•˜์—ฌ ์กฐํšŒ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isNotNull(); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("loginId")).isEqualTo("testuser"); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("name")).isEqualTo("ํ™๊ธธ*"); + } + ); + } + + @DisplayName("ํ—ค๋” ์ธ์ฆ ์‹คํŒจ ์‹œ (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜), 401 Unauthorized๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUnauthorized_whenHeaderPasswordIsWrong() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ํ‹€๋ฆฐ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์กฐํšŒ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํšŒ์›์˜ ID๋กœ ์กฐํšŒํ•˜๋ฉด, 404 Not Found๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsNotFound_whenMemberDoesNotExist() { + // arrange - ์•„๋ฌด ๋ฐ์ดํ„ฐ ์—†์Œ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PATCH /api/v1/members/me/password (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ)") + @Nested + class ChangePassword { + + @DisplayName("ํ—ค๋” ์ธ์ฆ ์„ฑ๊ณต + ์œ ํšจํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ์ด๋ฉด, 200 OK๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsOk_whenHeaderAuthAndPasswordChangeAreValid() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ํ—ค๋” ์ธ์ฆ + ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isEqualTo("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + ); + } + + @DisplayName("ํ—ค๋” ์ธ์ฆ ์‹คํŒจ ์‹œ (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜), 401 Unauthorized๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUnauthorized_whenHeaderPasswordIsWrong() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ํ‹€๋ฆฐ ํ—ค๋” ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์š”์ฒญ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("Body์˜ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด, 401 Unauthorized๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUnauthorized_whenOldPasswordInBodyDoesNotMatch() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ํ—ค๋”๋Š” ๋งž์ง€๋งŒ Body์˜ oldPassword๊ฐ€ ํ‹€๋ฆผ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "WrongPass1!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๊ฐ™์œผ๋ฉด, 400 Bad Request๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsBadRequest_whenNewPasswordIsSameAsOld() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ๊ธฐ์กด๊ณผ ๋™์ผํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝ ์š”์ฒญ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "Test1234!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8..b7f02279 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,10 +34,15 @@ allprojects { } subprojects { + val containerProjects = setOf("apps", "modules", "supports") + apply(plugin = "java") apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") - apply(plugin = "jacoco") + + if (name !in containerProjects) { + apply(plugin = "jacoco") + } dependencyManagement { imports { @@ -55,6 +60,8 @@ subprojects { // Lombok implementation("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + // ์•”ํ˜ธํ™” + implementation ("org.springframework.security:spring-security-crypto") // Test testRuntimeOnly("org.junit.platform:junit-platform-launcher") // testcontainers:mysql ์ด jdbc ์‚ฌ์šฉํ•จ @@ -85,24 +92,27 @@ subprojects { jvmArgs("-Xshare:off") } - tasks.withType { - mustRunAfter("test") - executionData(fileTree(layout.buildDirectory.asFile).include("jacoco/*.exec")) - reports { - xml.required = true - csv.required = false - html.required = false - } - afterEvaluate { - classDirectories.setFrom( - files( - classDirectories.files.map { - fileTree(it) - }, - ), - ) - } - } + // TODO: JDK 24 ํ™˜๊ฒฝ์—์„œ JacocoReport ํƒœ์Šคํฌ ์ƒ์„ฑ ์˜ค๋ฅ˜ ๋ฐœ์ƒ - JDK 21๋กœ ์ „ํ™˜ ํ›„ ํ™œ์„ฑํ™” ํ•„์š” + // if (name !in containerProjects) { + // tasks.withType { + // mustRunAfter("test") + // executionData(fileTree(layout.buildDirectory.asFile).include("jacoco/*.exec")) + // reports { + // xml.required = true + // csv.required = false + // html.required = false + // } + // afterEvaluate { + // classDirectories.setFrom( + // files( + // classDirectories.files.map { + // fileTree(it) + // }, + // ), + // ) + // } + // } + // } } // module-container ๋Š” task ๋ฅผ ์‹คํ–‰ํ•˜์ง€ ์•Š๋„๋ก ํ•œ๋‹ค. diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml index 18e5fcf5..6921cb39 100644 --- a/docker/infra-compose.yml +++ b/docker/infra-compose.yml @@ -3,7 +3,7 @@ services: mysql: image: mysql:8.0 ports: - - "3306:3306" + - "3307:3306" environment: - MYSQL_ROOT_PASSWORD=root - MYSQL_USER=application diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b..8b90b1c7 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -42,7 +42,7 @@ spring: datasource: mysql-jpa: main: - jdbc-url: jdbc:mysql://localhost:3306/loopers + jdbc-url: jdbc:mysql://localhost:3307/loopers username: application password: application