From a095bbb0a2f5471b3995c377f61d4d2cc5e96332 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 06:15:00 +0900 Subject: [PATCH 01/32] =?UTF-8?q?docs:=20=EA=B0=9C=EB=B0=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=9D=BC=EC=9D=B8=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: AI 에이전트 개발 가이드 및 제약사항 - TDD.md: 테스트 주도 개발 구현 가이드 Co-authored-by: Cursor --- AGENTS.md | 670 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ TDD.md | 266 ++++++++++++++++++++++ 2 files changed, 936 insertions(+) create mode 100644 AGENTS.md create mode 100644 TDD.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..64391319 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,670 @@ +# AGENTS.md - AI Agent Development Guidelines + +> **⚠️ CRITICAL**: This is the PRIMARY REFERENCE for all AI agents working on this project. All guidelines, constraints, and rules defined here MUST be strictly followed at all times. + +--- + +## 1. Project Context + +### Tech Stack & Versions + +``` +Java: 21 +Spring Boot: 3.4.4 +Kotlin: 2.0.20 (for build scripts only) +Spring Cloud: 2024.0.1 +QueryDSL: Latest (via jakarta) +MySQL: Latest connector +Redis: Latest +Kafka: Latest (Spring Kafka) +``` + +### Primary Libraries + +- **Web**: Spring Boot Starter Web, Actuator +- **ORM**: Spring Data JPA, QueryDSL +- **Serialization**: Jackson (JSR310 support) +- **Documentation**: SpringDoc OpenAPI 2.7.0 +- **Testing**: JUnit 5, Mockito 5.14.0, SpringMockK 4.0.2, Instancio JUnit 5.0.2 +- **Testcontainers**: MySQL, Redis, Kafka +- **Monitoring**: Actuator + Prometheus + Grafana +- **Logging**: Logback with Slack integration + +### Module Structure + +This is a **multi-module Gradle project** with three primary categories: + +#### `apps/` - Application Modules (Executable) + +- **`commerce-api`**: Main REST API application + - Layers: `interfaces` (Controllers) → `application` (Facades) → `domain` (Services, Models, Repositories) → `infrastructure` (JPA Implementations) + - Dependencies: jpa, redis, jackson, logging, monitoring modules +- **`commerce-batch`**: Spring Batch jobs + - Job configurations, tasklets, listeners +- **`commerce-streamer`**: Kafka consumer application + - Stream processing, Kafka listeners + +#### `modules/` - Infrastructure Modules (Reusable) + +- **`jpa`**: JPA configuration, BaseEntity, QueryDSL setup, Testcontainers for MySQL +- **`redis`**: Redis configuration, Testcontainers for Redis +- **`kafka`**: Kafka configuration, Testcontainers for Kafka + +#### `supports/` - Support Modules (Cross-cutting) + +- **`jackson`**: Jackson configuration (datetime, serialization) +- **`logging`**: Logback configuration, Slack appender +- **`monitoring`**: Actuator and metrics configuration + +### Code Architecture Pattern + +This project follows **Layered Architecture** with strict dependency rules: + +``` +interfaces (Controllers, DTOs, Specs) + ↓ +application (Facades, Info DTOs) + ↓ +domain (Services, Models, Repositories) + ↓ +infrastructure (JPA Repositories, External APIs) +``` + +**Key Principles**: + +- Domain layer MUST be infrastructure-agnostic (no Spring, JPA annotations in domain logic) +- Facades orchestrate business flows but DO NOT contain business logic +- Services contain all business logic and invariants +- Models (Entities) contain domain rules and validations +- Repository interfaces are defined in domain, implemented in infrastructure + +### User Domain Class Design + +User domain features (회원 가입, 내 정보 조회, 포인트 조회, 비밀번호 변경) implementation structure. + +#### Project Structure (12 Classes) + +``` +apps/commerce-api/src/main/java/com/loopers/ + +📦 domain/user/ [Domain Layer - Business Core] +├── UserModel.java # Entity (String fields, domain behavior) +├── UserService.java # Business logic (duplicate check, queries) +├── UserRepository.java # Repository interface +├── Password.java # VO (validation + encryption, transient) +├── Email.java # VO (validation, transient) +├── BirthDate.java # VO (validation, transient) +└── Gender.java # Enum (value restriction) + +📦 application/user/ [Application Layer - Orchestration] +├── UserFacade.java # Flow coordination (transaction boundary) +└── UserInfo.java # Response Info (with name masking) + +📦 interfaces/api/user/ [Interface Layer - API Entry Point] +├── UserV1Controller.java # REST Controller +├── UserV1Dto.java # Request/Response DTO (records) +└── UserV1ApiSpec.java # OpenAPI documentation + +📦 infrastructure/user/ [Infrastructure Layer - Persistence] +├── UserJpaRepository.java # Spring Data JPA +└── UserRepositoryImpl.java # Repository implementation +``` + +#### Entity Fields Use Primitive/String Types + +```java +@Entity +public class UserModel extends BaseEntity { + private String email; // Email VO의 value 추출 저장 + private String birthDate; // BirthDate VO의 value 추출 저장 + private String encryptedPassword; // Password.encrypt() 결과 저장 + private Gender gender; // Enum 직접 저장 + private Long points; +} +``` + +VOs validate on creation, then values are extracted: + +```java +Email emailVO = new Email(emailString); +this.email = emailVO.value(); + +Password password = Password.of(raw, birthDate); +this.encryptedPassword = password.encrypt(); +``` + +#### Class Responsibilities Summary + +| Class | Type | Field Types | Lifecycle | Responsibility | +| --------------- | ---------- | ----------- | ---------- | -------------------------------------- | +| **Email** | record | - | Transient | Email format validation (xx@yy.zz) | +| **BirthDate** | record | - | Transient | Date format validation (yyyy-MM-dd) | +| **Password** | class | String | Transient | Password rules + encryption | +| **Gender** | enum | - | - | Gender validation and conversion | +| **UserModel** | @Entity | String/Long | Persistent | User state management, domain behavior | +| **UserService** | @Component | - | Singleton | Business logic, duplicate check | +| **UserFacade** | @Component | - | Singleton | Flow orchestration, transactions | +| **UserInfo** | record | - | Transient | Domain → DTO conversion, masking | + +--- + +## 2. Development Rules + +### Augmented Coding Workflow + +**⚠️ CRITICAL PROCESS**: AI agents operate under human supervision with these **NON-NEGOTIABLE** rules: + +1. **Direction & Major Decisions**: + + - You MAY propose architectural changes, major refactoring, or new patterns + - You MUST wait for explicit approval before implementing them + - Never assume approval; always ask and wait for confirmation + +2. **Interim Reporting Obligation**: + + - Report progress at key milestones (e.g., after test implementation, before production code) + - If you detect you're repeating actions, implementing unrequested features, or deleting tests, **STOP and report immediately** + - Developer intervention is required when you deviate from instructions + +3. **Design Authority**: + - The human developer retains final authority on all design decisions + - Your role is to implement, suggest, and optimize—not to decide unilaterally + - Respect existing patterns unless explicitly asked to change them + +### TDD Implementation (Mandatory) + +**🚨 ABSOLUTE REQUIREMENT**: + +- **ALL production code MUST be driven by tests** +- **NEVER write production code without tests first** +- Refer to `TDD.md` for comprehensive testing guidelines, patterns, and strategies +- If `TDD.md` doesn't exist yet, create it based on project testing patterns before starting TDD work + +**Test Hierarchy**: + +1. **Unit Tests** (`@Test`): Domain model validations, business logic in services +2. **Integration Tests** (`@SpringBootTest`): Service layer with real DB (Testcontainers) +3. **E2E Tests** (`@SpringBootTest` + `@AutoConfigureMockMvc`): Full HTTP request/response cycle + +**Test Naming Convention**: + +``` +{메서드명}_{테스트_조건}_{예상_결과} +Example: signUp_withDuplicateId_shouldFail() +``` + +### Core Principles + +1. **Respect Layer Boundaries**: + + - Controllers MUST only delegate to Facades + - Facades MUST only orchestrate (no if/else policy branches) + - Services MUST contain all business logic + - Models MUST enforce domain invariants + +2. **Maintain Existing Patterns**: + + - Study existing code before implementing new features + - Follow established naming conventions, package structures, and patterns + - Consistency > innovation (unless explicitly asked to innovate) + +3. **Document-Driven Changes**: + - For structural changes (new module, layer, or pattern), update relevant docs FIRST + - Ensure `.codeguide/`, `README.md`, and this `AGENTS.md` stay synchronized + +### Branch & PR Strategy + +**Branch Naming Format**: `{type}/{context-detail}` + +**Types**: + +- `feature/` - New functionality (e.g., `feature/week1-user-signup`) +- `fix/` - Bug fixes (e.g., `fix/week2-point-calculation-bug`) +- `refactor/` - Code refactoring (e.g., `refactor/week3-service-layer`) +- `test/` - Test additions/fixes (e.g., `test/week1-integration-tests`) +- `docs/` - Documentation updates (e.g., `docs/update-api-specs`) + +**PR Guidelines**: + +- One feature = One branch = One PR +- PRs MUST include corresponding tests (unit + integration + E2E as applicable) +- PRs MUST pass all existing tests +- Follow `.github/pull_request_template.md` structure + +--- + +## 3. Constraints & Recommendations + +### ❌ Never Do (Strictly Forbidden) + +1. **Non-Functional Code**: + + - Never create stub methods with `TODO` comments + - Never use unnecessary mocks when real implementations exist + - Never leave `System.out.println()` or debugging logs + +2. **Null Safety Violations**: + + - Java: Use `Optional` for nullable returns, never return null from public methods + - Validate all inputs; fail fast with meaningful exceptions + +3. **Architecture Violations**: + + - ❌ Business logic in Controllers + - ❌ Policy branches (`if/else` based on business rules) in Facades + - ❌ Domain models importing Spring/JPA infrastructure (`@Autowired`, etc.) + - ❌ Direct repository calls from Controllers (must go through Facades) + +4. **Lombok Restrictions**: + + - **NEW code and entities**: DO NOT use Lombok + - Use explicit constructors, getters, and builders + - Exception: Existing code may retain Lombok temporarily (refactor gradually) + +5. **Test Anti-Patterns**: + + - Never use random data in tests (breaks reproducibility) + - Never delete existing tests without explicit approval + - Never skip writing tests to "save time" + +6. **Forbidden Shortcuts**: + - Never modify `BaseEntity`, `ApiResponse`, `ErrorType`, or authentication headers without approval + - Never change shared modules (`modules/`, `supports/`) without discussing impact + - Never commit secrets (`.env`, `credentials.json`, etc.) + +### ✅ Recommendations (Best Practices) + +1. **Reusable Object Design**: + + - Prefer composition over inheritance + - Create small, focused classes with single responsibilities + - Use records for immutable DTOs (Java 17+) + +2. **Performance Optimization**: + + - Suggest N+1 query solutions (QueryDSL fetch joins) + - Recommend caching strategies (Redis) when appropriate + - Flag potential bottlenecks in code reviews + +3. **API Documentation**: + + - After completing API endpoints, document them in `http/{app-name}/*.http` files + - Include examples for both success and error cases + - Use `http-client.env.json` for environment-specific variables + +4. **Code Quality**: + - Write self-documenting code (clear naming > comments) + - Add Javadoc for public APIs and complex logic + - Follow Java naming conventions (PascalCase for classes, camelCase for methods/variables) + +### 🛡️ Priority Checklist (Every Implementation) + +Before committing code, verify: + +- [ ] **Functionality**: Does it actually work? (Manual/automated testing) +- [ ] **Null Safety**: All nullable returns wrapped in `Optional`, inputs validated +- [ ] **Thread Safety**: No shared mutable state, consider concurrency implications +- [ ] **Testability**: Can this be easily tested? No hidden dependencies? +- [ ] **Pattern Consistency**: Does this match existing code patterns? +- [ ] **Layer Separation**: No architecture boundary violations? + +### 🔒 Protected Areas (Do Not Modify) + +The following structures are **locked** and require explicit approval to change: + +1. **`modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java`** + + - ID generation strategy, audit fields, lifecycle hooks + +2. **`apps/commerce-api/.../interfaces/api/ApiResponse.java`** + + - Response envelope format: `{ meta: { result, errorCode, message }, data }` + +3. **`apps/commerce-api/.../support/error/ErrorType.java`** + + - Standard error codes and HTTP status mappings + +4. **Authentication Headers**: + + - Customer API: `X-Loopers-LoginId`, `X-Loopers-LoginPw` + - Admin API: `X-Loopers-Ldap` + +5. **Shared Infrastructure Modules**: + - `modules/jpa`, `modules/redis`, `modules/kafka` + - `supports/jackson`, `supports/logging`, `supports/monitoring` + +--- + +## 4. API & Error Specifications + +### API Prefix & Authentication + +| API Type | Prefix | Auth Header(s) | Example | +| ------------ | -------------- | ---------------------------------------- | --------------------------------- | +| Customer API | `/customer/v1` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | `POST /customer/v1/users/sign-up` | +| Admin API | `/admin/v1` | `X-Loopers-Ldap` | `GET /admin/v1/orders` | + +### Standard Response Format + +**Success Response**: + +```json +{ + "meta": { + "result": "SUCCESS", + "errorCode": null, + "message": null + }, + "data": { + // response payload + } +} +``` + +**Error Response**: + +```json +{ + "meta": { + "result": "FAIL", + "errorCode": "BAD_REQUEST", + "message": "Invalid input parameters" + }, + "data": null +} +``` + +### Error Type Usage + +**Defined in**: `com.loopers.support.error.ErrorType` + +| ErrorType | HTTP Status | Usage | +| ---------------- | ----------- | ------------------------------------------ | +| `BAD_REQUEST` | 400 | Invalid input, validation failures | +| `NOT_FOUND` | 404 | Resource not found | +| `CONFLICT` | 409 | Duplicate resource, business rule conflict | +| `INTERNAL_ERROR` | 500 | Unexpected system errors | + +**Throwing Exceptions**: + +```java +throw new CoreException(ErrorType.BAD_REQUEST, "Email format is invalid"); +throw new CoreException(ErrorType.NOT_FOUND, "User not found with id: " + userId); +``` + +### HTTP File Documentation + +After implementing an endpoint, document it in `http/commerce-api/{domain}-v1.http`: + +```http +### Sign Up +POST http://localhost:8080/customer/v1/users/sign-up +Content-Type: application/json + +{ + "userId": "testuser01", + "password": "SecurePass123!", + "email": "test@example.com", + "birthDate": "1990-01-15", + "gender": "MALE" +} + +### Expected Success Response (201 Created) +### Expected Error Response (400 Bad Request) if userId already exists +``` + +--- + +## 5. Feature Implementation Guidelines + +Based on `.codeguide/loopers-1-week.md` and project requirements, follow these checklists: + +### 🔐 User Sign-Up + +**Business Rules**: + +- User ID: Alphanumeric, max 10 characters +- Email: Must match `xx@yy.zz` format +- Birth Date: Must match `yyyy-MM-dd` format +- Password: 8-16 characters, MUST NOT contain birth date substring +- Password MUST be encrypted (BCrypt or similar) +- User ID MUST be unique (check before insertion) +- Gender: Required field + +**Implementation Checklist**: + +- [ ] **Unit Tests**: + - [ ] User creation fails if userId format is invalid + - [ ] User creation fails if email format is invalid + - [ ] User creation fails if birthDate format is invalid + - [ ] User creation fails if password contains birthDate +- [ ] **Integration Tests**: + - [ ] Sign-up performs User save operation (verify with spy) + - [ ] Sign-up fails if userId already exists +- [ ] **E2E Tests**: + - [ ] Sign-up returns created user info on success + - [ ] Sign-up returns `400 Bad Request` if gender is missing + +**Endpoint**: `POST /customer/v1/users/sign-up` + +**Response**: + +```json +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": { + "userId": "testuser01", + "email": "test@example.com", + "birthDate": "1990-01-15", + "gender": "MALE" + } +} +``` + +### 👤 My Info Retrieval + +**Business Rules**: + +- User ID: Alphanumeric characters only +- Name Masking: Replace last character with `*` (e.g., "홍길동" → "홍길*", "John" → "Joh*") + +**Implementation Checklist**: + +- [ ] **Integration Tests**: + - [ ] Returns user info if user exists + - [ ] Returns null if user does not exist +- [ ] **E2E Tests**: + - [ ] Returns masked user info on success + - [ ] Returns `404 Not Found` if user does not exist + +**Endpoint**: `GET /customer/v1/users/me` + +**Headers**: `X-Loopers-LoginId: {userId}` + +**Response**: + +```json +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": { + "userId": "testuser01", + "name": "홍길*", + "email": "test@example.com", + "birthDate": "1990-01-15", + "gender": "MALE" + } +} +``` + +### 💰 Point Retrieval + +**Business Rules**: + +- Points are associated with User entity +- Only authenticated users can view their own points + +**Implementation Checklist**: + +- [ ] **Integration Tests**: + - [ ] Returns point balance if user exists + - [ ] Returns null if user does not exist +- [ ] **E2E Tests**: + - [ ] Returns point balance on success + - [ ] Returns `400 Bad Request` if `X-USER-ID` header is missing + +**Endpoint**: `GET /customer/v1/users/me/points` + +**Headers**: `X-USER-ID: {userId}` + +**Response**: + +```json +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": { + "userId": "testuser01", + "points": 10000 + } +} +``` + +### 🔄 Password Update + +**Business Rules**: + +- New password MUST be different from current password +- New password MUST follow same validation rules as sign-up (8-16 chars, no birth date substring) +- Current password MUST be verified before update + +**Implementation Checklist**: + +- [ ] Verify current password matches stored encrypted password +- [ ] Validate new password meets requirements +- [ ] Ensure new password differs from current password +- [ ] Encrypt new password before saving + +**Endpoint**: `PATCH /customer/v1/users/me/password` + +**Request**: + +```json +{ + "currentPassword": "OldPass123!", + "newPassword": "NewSecurePass456!" +} +``` + +--- + +## 6. Testing Strategy + +### Test Structure + +Each feature MUST have three test levels: + +1. **Unit Tests** (`src/test/.../domain/{entity}/*Test.java`): + + - Focus: Domain models, value objects, business logic + - Dependencies: None (pure Java, no Spring context) + - Example: `UserModelTest`, `PasswordValidatorTest` + +2. **Integration Tests** (`src/test/.../domain/{entity}/*IntegrationTest.java`): + + - Focus: Service layer with real database (Testcontainers) + - Dependencies: `@SpringBootTest`, JPA repositories, database + - Example: `UserServiceIntegrationTest` + +3. **E2E Tests** (`src/test/.../interfaces/api/*E2ETest.java`): + - Focus: HTTP request/response, full application context + - Dependencies: `@SpringBootTest`, `@AutoConfigureMockMvc`, MockMvc + - Example: `UserV1ApiE2ETest` + +### Test Data Management + +- Use **Instancio** for generating test data (avoid randomness for reproducibility) +- Use **`DatabaseCleanUp`** utility (from `jpa` module testFixtures) to clean DB between tests +- Use **`RedisCleanUp`** utility (from `redis` module testFixtures) to clean Redis between tests + +### Test Configuration + +- Profile: `spring.profiles.active=test` +- Timezone: `Asia/Seoul` (set in Gradle test task) +- Testcontainers: Auto-configured via `testFixtures` modules + +--- + +## 7. Workflow Summary + +### Before Starting Any Task + +1. Read this `AGENTS.md` document completely +2. Read `TDD.md` if implementing tests +3. Read `.codeguide/{relevant-guide}.md` for feature-specific requirements +4. Study existing code patterns in the same layer/domain +5. Propose your implementation plan and wait for approval + +### During Implementation + +1. **Write tests first** (unit → integration → E2E) +2. **Implement production code** to pass tests +3. **Run all tests** (`./gradlew test`) +4. **Document API** in `http/` directory +5. **Report progress** at key milestones + +### Before Committing + +1. Verify all tests pass +2. Check for architecture violations (use checklist in Section 3) +3. Remove debug logs and print statements +4. Ensure code follows existing patterns +5. Update documentation if necessary + +### PR Creation + +1. Create feature branch: `{type}/{context-detail}` +2. Ensure all tests pass locally +3. Push branch with descriptive commits +4. Create PR using template (`.github/pull_request_template.md`) +5. Wait for review and approval + +--- + +## 8. Additional Resources + +- **Build & Run**: `./gradlew bootRun -p apps/commerce-api` +- **Run Tests**: `./gradlew test` +- **Test Coverage**: `./gradlew jacocoTestReport` (XML report in `build/reports/jacoco/`) +- **API Docs**: `http://localhost:8080/swagger-ui.html` (when running) +- **Actuator**: `http://localhost:8080/actuator` (metrics, health) + +### Key Configuration Files + +- `gradle.properties`: Versions, project group +- `build.gradle.kts`: Common dependencies, test configuration +- `settings.gradle.kts`: Module definitions +- `apps/commerce-api/src/main/resources/application.yml`: Runtime configuration + +### Development Tools + +- **Docker Compose**: `docker/infra-compose.yml` (MySQL, Redis, Kafka) +- **Monitoring**: `docker/monitoring-compose.yml` (Prometheus, Grafana) +- **HTTP Client**: Use IntelliJ HTTP Client with files in `http/` directory + +--- + +## 9. Final Reminders + +> **This document is your PRIMARY REFERENCE. When in doubt:** +> +> 1. Re-read the relevant section in this document +> 2. Study existing code patterns +> 3. Ask for clarification before implementing +> 4. Never assume or guess—always verify + +**Key Mantras**: + +- ✅ Tests first, code second +- ✅ Real solutions only, no stubs or TODOs +- ✅ Layer boundaries are sacred +- ✅ Consistency over cleverness +- ✅ Report progress, don't work in silence \ No newline at end of file diff --git a/TDD.md b/TDD.md new file mode 100644 index 00000000..34ae37c7 --- /dev/null +++ b/TDD.md @@ -0,0 +1,266 @@ +# TDD Implementation Guide + +> This document defines **Test-Driven Development (TDD)** and **quality assurance principles** that MUST be followed for all feature implementations. + +> Production code without tests is **prohibited**. The cycle and principles below must be strictly adhered to. + +--- + +## 1. Development Workflow: TDD 3-Phase Cycle + +All test code is written based on the **3A principle (Arrange – Act – Assert)**. + +### Step 1. Red Phase (Write a failing test) + +- Write the **minimum failing test** that satisfies the requirement first. +- Compilation errors count as failure; define interfaces and specifications first. + +### Step 2. Green Phase (Write code to pass the test) + +- Write the **simplest code** that makes the Red Phase test pass. +- Avoid over-engineering (e.g. speculative future extensions) at this stage. + +### Step 3. Refactor Phase (Improve code quality) + +- Improve readability, remove duplication, and optimize while **keeping tests green**. +- Avoid exposing unnecessary `private` methods; re-evaluate object-oriented responsibilities. +- Remove unused imports and debug logs (e.g. `println`). +- Check for missing `final`, inadequate `Optional` handling, etc. + +--- + +## 2. Test Design Principles + +### Core design guidelines + +- **Isolate external dependencies**: External APIs, DB, and libraries are abstracted behind interfaces and injected via constructor (DI). +- **Cohesive business logic**: Logic is concentrated in domain entities or dedicated domain services. +- **State-centric design**: Maintain the structure `[Input] → [State change] → [Result verification]`. +- **Test levels**: Verify in order **Unit (domain)** → **Integration (application/DB)** → **E2E (API/HTTP)**. + +### Test double usage criteria + +- **Unit** + - **Target:** Domain model (Entity, VO, Policy) + - **Purpose:** Validate pure business logic and rules + - **Environment:** Plain JVM (no Spring) + - **Tech:** JUnit 5, AssertJ, Mockito + +- **Integration** + - **Target:** Service, Facade, Repository + - **Purpose:** Verify component collaboration and business flow + - **Environment:** `@SpringBootTest`, test DB (H2/Testcontainers) + - **Tech:** Spring Test Context, Mockito + +- **E2E** + - **Target:** Controller → DB full flow + - **Purpose:** Validate use-case scenarios via real HTTP requests + - **Environment:** `MockMvc` or `TestRestTemplate` + - **Tech:** `@AutoConfigureMockMvc`, RestAssured + +--- + +## 3. TDD-Based Feature Specs & Checklists + +Each feature is developed as a single commit unit. The AI must obtain developer approval after each step before proceeding. + +- All test methods follow the naming convention: `{methodName}_{testCondition}_{expectedResult}`. +- All tests are structured with **given–when–then** comments. +- Tests must be **independent** and runnable without depending on each other. + +### Exception handling strategy + +| Layer | Exception type | Notes | +|-------|----------------|--------| +| **Domain (VO, Entity)** | `IllegalArgumentException` | Pure Java | +| **Service** | `CoreException(ErrorType.XXX)` | Business rule violations | +| **API** | Spring maps to HTTP status codes | Automatic | + +### Transaction & quality + +- All CUD operations must run **inside a transaction** with full rollback on failure. +- Business exceptions must be mapped to appropriate HTTP status codes (400, 401, 404, 409, etc.). +- After each test level (Unit → Integration → E2E), provide a summary of passing tests. + +--- + +## 4. Feature 1: Sign-up + +**Goal:** Create a user with validated input and an encrypted password. + +### Step 1. Unit tests (Domain layer) + +#### UserId VO + +- [ ] **Red:** Creating ID longer than 10 characters throws `IllegalArgumentException`. +- [ ] **Red:** Creating UserId with special characters throws exception (UserId allows alphanumeric only). +- [ ] **Red:** Creating UserId with empty string throws `IllegalArgumentException`. +- [ ] **Green:** Creating valid UserId with 10 or fewer characters succeeds (boundary: 10 chars). +- [ ] **Green:** Alphanumeric 4–10 character UserId creates successfully. + +#### Email VO + +- [ ] **Red:** Missing `@` throws `IllegalArgumentException`. +- [ ] **Red:** Invalid domain format throws `IllegalArgumentException`. +- [ ] **Green:** Standard format `user@example.com` creates successfully. + +#### BirthDate VO + +- [ ] **Red:** Future date throws `IllegalArgumentException`. +- [ ] **Red:** Wrong format (not `yyyy-MM-dd`) throws `IllegalArgumentException`. +- [ ] **Green:** Past date with correct format creates successfully. + +#### Password VO (birth date must not be included per security policy) + +- [ ] **Red:** Fewer than 8 characters throws `IllegalArgumentException`. +- [ ] **Red:** More than 16 characters throws `IllegalArgumentException`. +- [ ] **Green:** Valid length 8–16 creates successfully. + +#### PasswordPolicy + +- [ ] **Red:** Password containing birth date as `yyyyMMdd` violates policy. +- [ ] **Red:** Password containing birth date as `yyMMdd` violates policy. +- [ ] **Red:** Password containing birth date with hyphen `yyyy-MM-dd` violates policy. +- [ ] **Green:** 8–16 chars without birth date creates successfully. + +#### Gender enum + +- [ ] **Red:** Invalid string format throws `IllegalArgumentException`. +- [ ] **Green:** String `'MALE'` creates successfully. +- [ ] **Green:** String `'FEMALE'` creates successfully. + +#### UserModel entity + +- [ ] **Green:** `create_withValidInputs_shouldInitializePointsToZero()`. +- [ ] **Green:** `create_shouldStoreExtractedEmailValue()`. + +#### Refactor + +- [ ] Improve readability of VO validation logic and remove duplication. +- [ ] Extract birth-date pattern logic into `PasswordPolicy` (SRP). + +--- + +### Step 2. Integration tests (Application layer) + +- [ ] **Red:** Duplicate ID sign-up attempt throws `ConflictException`. +- [ ] **Red (Concurrency):** Concurrent sign-up with same ID: only one succeeds, others fail with exception. +- [ ] **Green:** On successful sign-up, `UserRepository.save()` is called (verify/spy). +- [ ] **Green:** DB unique constraint violation is translated to `ConflictException` in service layer. +- [ ] **Green:** Password is stored encrypted (no plaintext). +- [ ] **Green:** Stored password is hashed with configured algorithm (e.g. BCrypt) and verifiable via `PasswordEncoder.matches()`. +- [ ] **Refactor:** Check missing `final` and dependency injection structure. + +--- + +### Step 3. E2E tests (API layer) + +- [ ] **Red:** Missing gender returns `400 Bad Request`. +- [ ] **Red:** Missing user ID returns `400 Bad Request`. +- [ ] **Green:** Valid sign-up returns `201 Created` and created user info (ID, etc.). +- [ ] **Green:** Request with existing ID returns `409 Conflict`. +- [ ] **Refactor:** DTO validation annotations (`@NotBlank`, `@Pattern`, etc.). +- [ ] **Refactor:** `ApiResponse` structure consistency (meta / data). + +--- + +## 5. Feature 2: My Info + +**Goal:** Retrieve user info according to security policy and return it with name masking. + +### Step 1. Unit tests (Domain / Policy layer) + +#### NameMaskingPolicy + +- [ ] **Red:** Masking null name throws exception (name must exist). +- [ ] **Red:** Masking empty string name throws exception (invalid name). +- [ ] **Red:** Masking name that is only whitespace throws exception (invalid after trim). +- [ ] **Red:** Masking single-character name throws exception (below minimum length for policy). +- [ ] **Green:** Two-character name: last character masked (e.g. "김철" → "김*"). +- [ ] **Green:** Three or more characters: only last character masked (e.g. "홍길동" → "홍길*"). +- [ ] **Green:** English name: last character masked (e.g. "Alan" → "Ala*"). +- [ ] **Refactor:** Edge-case handling consistency; remove redundant conditionals. + +--- + +### Step 2. Integration tests (Application layer) + +#### UserService + +- [ ] **Red:** My-info for non-existent user ID returns null (invalid user handling). +- [ ] **Green:** My-info for existing user ID returns user info (happy path). +- [ ] **Green:** My-info response has name masking policy applied. +- [ ] **Green:** My-info response does not include encrypted password (no sensitive data exposure). +- [ ] **Refactor:** Optional usage and null safety; service method naming consistency. + +--- + +### Step 3. E2E tests (API layer) + +- [ ] **Red:** Missing auth header returns `401 Unauthorized`. +- [ ] **Red:** Request with non-existent ID returns `404 Not Found`. +- [ ] **Green:** Valid request returns `200 OK` and correct JSON body. +- [ ] **Refactor:** Response JSON (wrapper) consistency; move auth to global Filter/Interceptor to avoid controller duplication. + +--- + +## 6. Feature 3: Point retrieval + +**Goal:** Accurately retrieve the current point balance for a given user. + +### Step 1. Integration tests (Application layer) + +#### UserService + +- [ ] **Red:** Point lookup for non-existent user ID returns null (invalid user). +- [ ] **Red:** User with no point data: failing test for default 0 return (if applicable). +- [ ] **Green:** New user point lookup returns 0 (default point invariant). +- [ ] **Green:** After point accrual, lookup returns correct accumulated points. +- [ ] **Refactor:** Point logic lives in domain model; service layer only handles retrieval. + +--- + +### Step 2. E2E tests (API layer) + +- [ ] **Red:** Point request without login ID header returns `400 Bad Request`. +- [ ] **Green:** Authenticated request returns `200 OK` and point info. +- [ ] **Refactor:** Update API docs (e.g. Swagger); header name consistency (`X-Loopers-LoginId`). + +--- + +## 7. Feature 4: Password update + +**Goal:** Safely update password after verifying current password and policy. + +### Step 1. Unit tests (Domain layer) + +#### UserModel + +- [ ] **Red:** Changing to same password as current throws exception (no reuse). +- [ ] **Red:** Changing to password shorter than 8 chars throws exception. +- [ ] **Red:** Changing to password longer than 16 chars throws exception. +- [ ] **Green:** Valid new password change updates password correctly in domain. +- [ ] **Refactor:** Encapsulation of `changePassword`; reuse of Password VO. + +--- + +### Step 2. Integration tests (Application layer) + +#### UserService + +- [ ] **Red:** Wrong current password causes change to fail. +- [ ] **Red:** Current password mismatch causes change to fail (auth failure). +- [ ] **Red:** New password containing birth date causes change to fail (policy). +- [ ] **Green:** Valid input results in password update persisted in DB. +- [ ] **Green:** Password is encrypted before save (no plaintext). +- [ ] **Green:** Same password produces different hash each time (salt). +- [ ] **Refactor:** Minimize DB access (dirty checking); clarify `PasswordEncoder` dependency. + +--- + +### Step 3. E2E tests (API layer) + +- [ ] **Red:** Password change without login header returns `401 Unauthorized`. +- [ ] **Red:** Wrong current password returns `400 Bad Request`. +- [ ] **Green:** Valid password change request returns `204 No Content`. +- [ ] **Refactor:** Remove unused imports; final code convention check. From 85b660b5070a32c2c1a72dcbaa4460277fbeb866 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 06:15:26 +0900 Subject: [PATCH 02/32] =?UTF-8?q?chore:=20=ED=9A=8C=EC=9B=90=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domain/user: 도메인 모델 및 비즈니스 로직 - application/user: 애플리케이션 서비스 및 파사드 - infrastructure/user: JPA 리포지토리 구현 - interfaces/api/user: REST API 컨트롤러 - 각 레이어별 테스트 패키지 생성 Co-authored-by: Cursor --- .../src/main/java/com/loopers/application/user/.gitkeep | 0 apps/commerce-api/src/main/java/com/loopers/domain/user/.gitkeep | 0 .../src/main/java/com/loopers/infrastructure/user/.gitkeep | 0 .../src/main/java/com/loopers/interfaces/api/user/.gitkeep | 0 .../src/test/java/com/loopers/application/user/.gitkeep | 0 apps/commerce-api/src/test/java/com/loopers/domain/user/.gitkeep | 0 .../src/test/java/com/loopers/interfaces/api/user/.gitkeep | 0 7 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/.gitkeep create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/.gitkeep create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/.gitkeep create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/.gitkeep create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/user/.gitkeep create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/.gitkeep create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/.gitkeep diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/.gitkeep b/apps/commerce-api/src/main/java/com/loopers/application/user/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/.gitkeep b/apps/commerce-api/src/main/java/com/loopers/domain/user/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/.gitkeep b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/.gitkeep b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/.gitkeep b/apps/commerce-api/src/test/java/com/loopers/application/user/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/.gitkeep b/apps/commerce-api/src/test/java/com/loopers/domain/user/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/.gitkeep b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/.gitkeep new file mode 100644 index 00000000..e69de29b From 3218933096be2ccb08cdf02d9f49cb57d6b084a9 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 06:35:28 +0900 Subject: [PATCH 03/32] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=8F=84=EB=A9=94=EC=9D=B8=20VO=20=EB=B0=8F=20Enum?= =?UTF-8?q?=20Unit=20Tests=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Email VO: 이메일 형식 검증 (xx@yy.zz) - BirthDate VO: 날짜 형식 및 미래 날짜 검증 (yyyy-MM-dd) - Password VO: 길이(8-16자) 및 생년월일 포함 여부 검증, BCrypt 암호화 - Gender Enum: MALE/FEMALE 값 검증 - Spring Security Crypto 의존성 추가 (비밀번호 암호화) - 총 17개 Unit Tests 작성 및 통과 Co-authored-by: Cursor --- apps/commerce-api/build.gradle.kts | 3 + .../com/loopers/domain/user/BirthDate.java | 26 ++++ .../java/com/loopers/domain/user/Email.java | 18 +++ .../java/com/loopers/domain/user/Gender.java | 18 +++ .../com/loopers/domain/user/Password.java | 59 +++++++++ .../loopers/domain/user/BirthDateTest.java | 68 ++++++++++ .../com/loopers/domain/user/EmailTest.java | 53 ++++++++ .../com/loopers/domain/user/GenderTest.java | 54 ++++++++ .../com/loopers/domain/user/PasswordTest.java | 116 ++++++++++++++++++ 9 files changed, 415 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/GenderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..71115c26 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -10,6 +10,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // security + implementation("org.springframework.security:spring-security-crypto") // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java new file mode 100644 index 00000000..a0e5d204 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -0,0 +1,26 @@ +package com.loopers.domain.user; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +public record BirthDate(String value) { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + public BirthDate { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("생년월일은 비어있을 수 없습니다."); + } + + LocalDate date; + try { + date = LocalDate.parse(value, FORMATTER); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("생년월일 형식은 yyyy-MM-dd 이어야 합니다."); + } + + if (date.isAfter(LocalDate.now())) { + throw new IllegalArgumentException("생년월일은 미래 날짜일 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java new file mode 100644 index 00000000..82cdcc80 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -0,0 +1,18 @@ +package com.loopers.domain.user; + +public record Email(String value) { + public Email { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("이메일은 비어있을 수 없습니다."); + } + + if (!value.contains("@")) { + throw new IllegalArgumentException("이메일은 @를 포함해야 합니다."); + } + + String[] parts = value.split("@"); + if (parts.length != 2 || parts[1].isBlank()) { + throw new IllegalArgumentException("이메일 도메인 형식이 올바르지 않습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java new file mode 100644 index 00000000..9461afc1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java @@ -0,0 +1,18 @@ +package com.loopers.domain.user; + +public enum Gender { + MALE, + FEMALE; + + public static Gender from(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("성별은 비어있을 수 없습니다."); + } + + try { + return Gender.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("성별은 MALE 또는 FEMALE이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java new file mode 100644 index 00000000..011d66fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -0,0 +1,59 @@ +package com.loopers.domain.user; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +public class Password { + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + private final String value; + + private Password(String value) { + this.value = value; + } + + public static Password of(String rawPassword, BirthDate birthDate) { + validateLength(rawPassword); + validateNotContainsBirthDate(rawPassword, birthDate); + return new Password(rawPassword); + } + + private static void validateLength(String password) { + if (password == null || password.isBlank()) { + throw new IllegalArgumentException("비밀번호는 비어있을 수 없습니다."); + } + if (password.length() < MIN_LENGTH) { + throw new IllegalArgumentException("비밀번호는 최소 8자 이상이어야 합니다."); + } + if (password.length() > MAX_LENGTH) { + throw new IllegalArgumentException("비밀번호는 최대 16자 이하여야 합니다."); + } + } + + private static void validateNotContainsBirthDate(String password, BirthDate birthDate) { + String birthDateValue = birthDate.value(); + + // yyyy-MM-dd 형식 + if (password.contains(birthDateValue)) { + throw new IllegalArgumentException("비밀번호에 생년월일이 포함될 수 없습니다."); + } + + // yyyyMMdd 형식 + String yyyyMMdd = birthDateValue.replace("-", ""); + if (password.contains(yyyyMMdd)) { + throw new IllegalArgumentException("비밀번호에 생년월일이 포함될 수 없습니다."); + } + + // yyMMdd 형식 + String yyMMdd = yyyyMMdd.substring(2); + if (password.contains(yyMMdd)) { + throw new IllegalArgumentException("비밀번호에 생년월일이 포함될 수 없습니다."); + } + } + + public String encrypt() { + return passwordEncoder.encode(value); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java new file mode 100644 index 00000000..420e45ed --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -0,0 +1,68 @@ +package com.loopers.domain.user; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BirthDateTest { + + @DisplayName("생년월일을 생성할 때, ") + @Nested + class Create { + + @DisplayName("미래 날짜이면, IllegalArgumentException이 발생한다.") + @Test + void create_withFutureDate_shouldFail() { + // given + String futureDate = "2999-12-31"; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + new BirthDate(futureDate); + }); + } + + @DisplayName("형식이 yyyy-MM-dd가 아니면, IllegalArgumentException이 발생한다.") + @Test + void create_withInvalidFormat_shouldFail() { + // given + String invalidFormat = "1990/01/15"; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + new BirthDate(invalidFormat); + }); + } + + @DisplayName("올바른 형식(yyyy-MM-dd)의 과거 날짜이면, 정상적으로 생성된다.") + @Test + void create_withValidPastDate_shouldSuccess() { + // given + String validDate = "1990-01-15"; + + // when + BirthDate birthDate = new BirthDate(validDate); + + // then + assertThat(birthDate.value()).isEqualTo(validDate); + } + + @DisplayName("오늘 날짜이면, 정상적으로 생성된다.") + @Test + void create_withToday_shouldSuccess() { + // given + String today = LocalDate.now().toString(); + + // when + BirthDate birthDate = new BirthDate(today); + + // then + assertThat(birthDate.value()).isEqualTo(today); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java new file mode 100644 index 00000000..77aae362 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -0,0 +1,53 @@ +package com.loopers.domain.user; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EmailTest { + + @DisplayName("이메일을 생성할 때, ") + @Nested + class Create { + + @DisplayName("@가 없으면, IllegalArgumentException이 발생한다.") + @Test + void create_withoutAtSign_shouldFail() { + // given + String invalidEmail = "invalidemail"; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + new Email(invalidEmail); + }); + } + + @DisplayName("도메인 형식이 잘못되면, IllegalArgumentException이 발생한다.") + @Test + void create_withInvalidDomain_shouldFail() { + // given + String invalidEmail = "user@"; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + new Email(invalidEmail); + }); + } + + @DisplayName("올바른 형식(xx@yy.zz)이면, 정상적으로 생성된다.") + @Test + void create_withValidFormat_shouldSuccess() { + // given + String validEmail = "user@example.com"; + + // when + Email email = new Email(validEmail); + + // then + assertThat(email.value()).isEqualTo(validEmail); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/GenderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/GenderTest.java new file mode 100644 index 00000000..f66fce06 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/GenderTest.java @@ -0,0 +1,54 @@ +package com.loopers.domain.user; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class GenderTest { + + @DisplayName("성별을 생성할 때, ") + @Nested + class Create { + + @DisplayName("잘못된 문자열 형식이면, IllegalArgumentException이 발생한다.") + @Test + void create_withInvalidString_shouldFail() { + // given + String invalidGender = "INVALID"; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + Gender.from(invalidGender); + }); + } + + @DisplayName("'MALE' 문자열이면, 정상적으로 생성된다.") + @Test + void create_withMaleString_shouldSuccess() { + // given + String male = "MALE"; + + // when + Gender gender = Gender.from(male); + + // then + assertThat(gender).isEqualTo(Gender.MALE); + } + + @DisplayName("'FEMALE' 문자열이면, 정상적으로 생성된다.") + @Test + void create_withFemaleString_shouldSuccess() { + // given + String female = "FEMALE"; + + // when + Gender gender = Gender.from(female); + + // then + assertThat(gender).isEqualTo(Gender.FEMALE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java new file mode 100644 index 00000000..4b10704a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -0,0 +1,116 @@ +package com.loopers.domain.user; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PasswordTest { + + @DisplayName("비밀번호를 생성할 때, ") + @Nested + class Create { + + @DisplayName("8자 미만이면, IllegalArgumentException이 발생한다.") + @Test + void create_withLessThan8Characters_shouldFail() { + // given + String shortPassword = "Pass12!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + Password.of(shortPassword, birthDate); + }); + } + + @DisplayName("16자를 초과하면, IllegalArgumentException이 발생한다.") + @Test + void create_withMoreThan16Characters_shouldFail() { + // given + String longPassword = "Pass1234567890123!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + Password.of(longPassword, birthDate); + }); + } + + @DisplayName("생년월일(yyyyMMdd)이 포함되면, IllegalArgumentException이 발생한다.") + @Test + void create_withBirthDateYyyyMMdd_shouldFail() { + // given + String password = "Pass19900115!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + Password.of(password, birthDate); + }); + } + + @DisplayName("생년월일(yyMMdd)이 포함되면, IllegalArgumentException이 발생한다.") + @Test + void create_withBirthDateYyMMdd_shouldFail() { + // given + String password = "Pass900115!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + Password.of(password, birthDate); + }); + } + + @DisplayName("생년월일(yyyy-MM-dd)이 포함되면, IllegalArgumentException이 발생한다.") + @Test + void create_withBirthDateWithHyphen_shouldFail() { + // given + String password = "Pass1990-01-15!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + Password.of(password, birthDate); + }); + } + + @DisplayName("8-16자이고 생년월일이 포함되지 않으면, 정상적으로 생성된다.") + @Test + void create_withValidPasswordWithoutBirthDate_shouldSuccess() { + // given + String validPassword = "SecurePass1!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when + Password password = Password.of(validPassword, birthDate); + + // then + assertThat(password).isNotNull(); + } + } + + @DisplayName("비밀번호를 암호화할 때, ") + @Nested + class Encrypt { + + @DisplayName("암호화된 값이 원본과 달라야 한다.") + @Test + void encrypt_shouldReturnDifferentValue() { + // given + String rawPassword = "SecurePass1!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of(rawPassword, birthDate); + + // when + String encrypted = password.encrypt(); + + // then + assertThat(encrypted).isNotEqualTo(rawPassword); + assertThat(encrypted).isNotBlank(); + } + } +} From 7a2ea5643209459e5d33baf649e1c881ce83f358 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 06:46:36 +0900 Subject: [PATCH 04/32] =?UTF-8?q?test:=20UserModel=20Entity=20Unit=20Tests?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserModel Entity 구현 (BaseEntity 상속) - 필드: userId, email, birthDate, encryptedPassword, gender, points - VO의 value 추출하여 String으로 저장 (AGENTS.md 원칙) - Password 자동 암호화 및 포인트 초기값 0L 설정 - 팩토리 메서드 패턴 (create) 적용 - 4개 Unit Tests 작성 및 통과 - 총 21개 Domain Unit Tests 완료 (100% 통과) Co-authored-by: Cursor --- .../com/loopers/domain/user/UserModel.java | 91 ++++++++++++++++++ .../loopers/domain/user/UserModelTest.java | 92 +++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java new file mode 100644 index 00000000..27e84685 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -0,0 +1,91 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +@Entity +@Table(name = "users") +public class UserModel extends BaseEntity { + + @Column(name = "user_id", nullable = false, unique = true, length = 10) + private String userId; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "birth_date", nullable = false) + private String birthDate; + + @Column(name = "encrypted_password", nullable = false) + private String encryptedPassword; + + @Enumerated(EnumType.STRING) + @Column(name = "gender", nullable = false) + private Gender gender; + + @Column(name = "points", nullable = false) + private Long points; + + protected UserModel() {} + + private UserModel( + String userId, + String email, + String birthDate, + String encryptedPassword, + Gender gender, + Long points + ) { + this.userId = userId; + this.email = email; + this.birthDate = birthDate; + this.encryptedPassword = encryptedPassword; + this.gender = gender; + this.points = points; + } + + public static UserModel create( + String userId, + Email email, + BirthDate birthDate, + Password password, + Gender gender + ) { + return new UserModel( + userId, + email.value(), + birthDate.value(), + password.encrypt(), + gender, + 0L + ); + } + + public String getUserId() { + return userId; + } + + public String getEmail() { + return email; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEncryptedPassword() { + return encryptedPassword; + } + + public Gender getGender() { + return gender; + } + + public Long getPoints() { + return points; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java new file mode 100644 index 00000000..6d522ffe --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.user; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class UserModelTest { + + @DisplayName("사용자를 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 입력값이 주어지면, 포인트가 0으로 초기화된다.") + @Test + void create_withValidInputs_shouldInitializePointsToZero() { + // given + String userId = "testuser01"; + Email email = new Email("test@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("SecurePass1!", birthDate); + Gender gender = Gender.MALE; + + // when + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + + // then + assertAll( + () -> assertThat(user.getUserId()).isEqualTo(userId), + () -> assertThat(user.getPoints()).isEqualTo(0L), + () -> assertThat(user.getGender()).isEqualTo(gender) + ); + } + + @DisplayName("Email VO의 value 값이 추출되어 저장된다.") + @Test + void create_shouldStoreExtractedEmailValue() { + // given + String userId = "testuser02"; + String emailValue = "user@example.com"; + Email email = new Email(emailValue); + BirthDate birthDate = new BirthDate("1995-03-20"); + Password password = Password.of("MyPass123!", birthDate); + Gender gender = Gender.FEMALE; + + // when + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + + // then + assertThat(user.getEmail()).isEqualTo(emailValue); + } + + @DisplayName("BirthDate VO의 value 값이 추출되어 저장된다.") + @Test + void create_shouldStoreExtractedBirthDateValue() { + // given + String userId = "testuser03"; + String birthDateValue = "1988-12-25"; + Email email = new Email("test3@example.com"); + BirthDate birthDate = new BirthDate(birthDateValue); + Password password = Password.of("Pass1234!", birthDate); + Gender gender = Gender.MALE; + + // when + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + + // then + assertThat(user.getBirthDate()).isEqualTo(birthDateValue); + } + + @DisplayName("Password가 암호화되어 저장된다.") + @Test + void create_shouldStoreEncryptedPassword() { + // given + String userId = "testuser04"; + String rawPassword = "RawPass123!"; + Email email = new Email("test4@example.com"); + BirthDate birthDate = new BirthDate("1992-06-10"); + Password password = Password.of(rawPassword, birthDate); + Gender gender = Gender.FEMALE; + + // when + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + + // then + assertThat(user.getEncryptedPassword()).isNotEqualTo(rawPassword); + assertThat(user.getEncryptedPassword()).isNotBlank(); + } + } +} From c6f2152629934401199ee49f2f9d5a5f209f3fc2 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 06:57:45 +0900 Subject: [PATCH 05/32] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20Domain=20Layer=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserRepository 인터페이스 정의 (save, findByUserId, existsByUserId) - UserService 비즈니스 로직 구현 - 중복 사용자 ID 체크 및 CONFLICT 예외 처리 - @Transactional 적용 - 회원 생성 및 저장 Co-authored-by: Cursor --- .../loopers/domain/user/UserRepository.java | 12 +++++++ .../com/loopers/domain/user/UserService.java | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 00000000..1ae96f2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + + UserModel save(UserModel user); + + Optional findByUserId(String userId); + + boolean existsByUserId(String userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 00000000..3f837326 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,32 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Transactional + public UserModel signUp( + String userId, + Email email, + BirthDate birthDate, + Password password, + Gender gender + ) { + if (userRepository.existsByUserId(userId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 사용자 ID입니다: " + userId); + } + + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + return userRepository.save(user); + } +} From f0cde0ee8fdaa9c520fb27a2065b91a94526953d Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 06:57:53 +0900 Subject: [PATCH 06/32] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20Infrastructure=20Layer=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserJpaRepository (Spring Data JPA 인터페이스) - findByUserId, existsByUserId 쿼리 메서드 - UserRepositoryImpl (Repository 구현체) - Domain의 UserRepository 인터페이스 구현 - UserJpaRepository 위임 Co-authored-by: Cursor --- .../user/UserJpaRepository.java | 13 ++++++++ .../user/UserRepositoryImpl.java | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 00000000..168908e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + + Optional findByUserId(String userId); + + boolean existsByUserId(String userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..9f240dde --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + public UserRepositoryImpl(UserJpaRepository userJpaRepository) { + this.userJpaRepository = userJpaRepository; + } + + @Override + public UserModel save(UserModel user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByUserId(String userId) { + return userJpaRepository.findByUserId(userId); + } + + @Override + public boolean existsByUserId(String userId) { + return userJpaRepository.existsByUserId(userId); + } +} From 54e73495410b46a95df3bb9786f3b2b4e20387b4 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 09:09:52 +0900 Subject: [PATCH 07/32] =?UTF-8?q?refactor:=20Lombok=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20UserMo?= =?UTF-8?q?del=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: Lombok 사용 가이드라인 업데이트 - VO/DTO: Java record 사용 (Lombok 불필요) - Entity: Lombok 허용 (@Getter, @NoArgsConstructor) - Exception/Enum: Lombok 허용 - UserModel: Lombok 적용으로 보일러플레이트 제거 (-26줄) - @Getter: 모든 필드 getter 자동 생성 - @NoArgsConstructor(access = PROTECTED): JPA용 기본 생성자 Co-authored-by: Cursor --- AGENTS.md | 10 +++--- .../com/loopers/domain/user/UserModel.java | 32 ++++--------------- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64391319..3f42db42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,11 +255,13 @@ Example: signUp_withDuplicateId_shouldFail() - ❌ Domain models importing Spring/JPA infrastructure (`@Autowired`, etc.) - ❌ Direct repository calls from Controllers (must go through Facades) -4. **Lombok Restrictions**: +4. **Lombok Usage Guidelines**: - - **NEW code and entities**: DO NOT use Lombok - - Use explicit constructors, getters, and builders - - Exception: Existing code may retain Lombok temporarily (refactor gradually) + - **VO/DTO**: Use Java `record` (Lombok not needed) + - **Entity**: Lombok allowed (`@Getter`, `@NoArgsConstructor(access = PROTECTED)`) + - **Exception/Enum**: Lombok allowed (`@Getter`, `@RequiredArgsConstructor`) + - **Service/Facade**: Avoid Lombok in business logic (constructor injection only) + - **Rationale**: Balance between code brevity and explicit domain logic 5. **Test Anti-Patterns**: diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index 27e84685..df127200 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -6,9 +6,15 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static lombok.AccessLevel.PROTECTED; @Entity @Table(name = "users") +@Getter +@NoArgsConstructor(access = PROTECTED) public class UserModel extends BaseEntity { @Column(name = "user_id", nullable = false, unique = true, length = 10) @@ -30,8 +36,6 @@ public class UserModel extends BaseEntity { @Column(name = "points", nullable = false) private Long points; - protected UserModel() {} - private UserModel( String userId, String email, @@ -64,28 +68,4 @@ public static UserModel create( 0L ); } - - public String getUserId() { - return userId; - } - - public String getEmail() { - return email; - } - - public String getBirthDate() { - return birthDate; - } - - public String getEncryptedPassword() { - return encryptedPassword; - } - - public Gender getGender() { - return gender; - } - - public Long getPoints() { - return points; - } } From 5f76214a4851bf28ab9fe891a1f9949ad5b56d73 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 09:11:30 +0900 Subject: [PATCH 08/32] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20Service=20Integration=20Tests=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 중복 사용자 ID 가입 시 CONFLICT 예외 검증 - 동시 회원가입 처리 검증 (1개만 성공, 나머지 실패) - UserRepository.save() 호출 검증 - 비밀번호 암호화 저장 검증 - BCrypt 알고리즘 검증 - 5개 Integration Tests 작성 - 참고: Testcontainers 환경 설정 필요 (Docker) Co-authored-by: Cursor --- .../user/UserServiceIntegrationTest.java | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 00000000..288852ad --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,170 @@ +package com.loopers.domain.user; + +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; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 할 때, ") + @Nested + class SignUp { + + @DisplayName("중복된 사용자 ID로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void signUp_withDuplicateUserId_shouldThrowConflictException() { + // given + String duplicateUserId = "duplicate01"; + Email email1 = new Email("user1@example.com"); + BirthDate birthDate1 = new BirthDate("1990-01-15"); + Password password1 = Password.of("Pass1234!", birthDate1); + Gender gender1 = Gender.MALE; + + userService.signUp(duplicateUserId, email1, birthDate1, password1, gender1); + + Email email2 = new Email("user2@example.com"); + BirthDate birthDate2 = new BirthDate("1995-05-20"); + Password password2 = Password.of("Pass5678!", birthDate2); + Gender gender2 = Gender.FEMALE; + + // when & then + CoreException exception = assertThrows(CoreException.class, () -> { + userService.signUp(duplicateUserId, email2, birthDate2, password2, gender2); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + assertThat(exception.getMessage()).contains("이미 존재하는 사용자 ID입니다"); + } + + @DisplayName("동시에 같은 ID로 가입하면, 1개만 성공하고 나머지는 실패한다.") + @Test + void signUp_withConcurrentSameUserId_shouldOnlyOneSucceed() throws InterruptedException { + // given + String userId = "concurrent01"; + int threadCount = 5; + CountDownLatch latch = new CountDownLatch(threadCount); + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // when + for (int i = 0; i < threadCount; i++) { + int index = i; + executorService.submit(() -> { + try { + Email email = new Email("user" + index + "@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("Pass1234!", birthDate); + Gender gender = Gender.MALE; + + userService.signUp(userId, email, birthDate, password, gender); + successCount.incrementAndGet(); + } catch (CoreException e) { + if (e.getErrorType() == ErrorType.CONFLICT) { + failCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(threadCount - 1); + } + + @DisplayName("성공 시 UserRepository.save()가 호출된다.") + @Test + void signUp_shouldCallRepositorySave() { + // given + String userId = "testuser01"; + Email email = new Email("test@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("SecurePass1!", birthDate); + Gender gender = Gender.MALE; + + // when + UserModel savedUser = userService.signUp(userId, email, birthDate, password, gender); + + // then + assertThat(savedUser).isNotNull(); + assertThat(savedUser.getId()).isNotNull(); + assertThat(savedUser.getUserId()).isEqualTo(userId); + } + + @DisplayName("비밀번호가 암호화되어 저장된다.") + @Test + void signUp_shouldStoreEncryptedPassword() { + // given + String userId = "testuser02"; + String rawPassword = "RawPassword123!"; + Email email = new Email("test2@example.com"); + BirthDate birthDate = new BirthDate("1992-06-10"); + Password password = Password.of(rawPassword, birthDate); + Gender gender = Gender.FEMALE; + + // when + UserModel savedUser = userService.signUp(userId, email, birthDate, password, gender); + + // then + assertThat(savedUser.getEncryptedPassword()).isNotEqualTo(rawPassword); + assertThat(savedUser.getEncryptedPassword()).isNotBlank(); + } + + @DisplayName("암호화된 비밀번호는 BCrypt로 검증 가능하다.") + @Test + void signUp_shouldEncryptPasswordWithBCrypt() { + // given + String userId = "testuser03"; + String rawPassword = "VerifyPass456!"; + Email email = new Email("test3@example.com"); + BirthDate birthDate = new BirthDate("1988-12-25"); + Password password = Password.of(rawPassword, birthDate); + Gender gender = Gender.MALE; + + // when + UserModel savedUser = userService.signUp(userId, email, birthDate, password, gender); + + // then + boolean matches = passwordEncoder.matches(rawPassword, savedUser.getEncryptedPassword()); + assertThat(matches).isTrue(); + } + } +} From 2702946e66ebdfbb85d8b4ee3f2a79adf639da2a Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 14:41:12 +0900 Subject: [PATCH 09/32] =?UTF-8?q?fix:=20Integration=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=86=B5=EA=B3=BC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Testcontainers 설정 추가로 테스트 환경 격리 - DB 컬럼 길이 명시 (email: 255, birth_date: 10, encrypted_password: 255) - DataIntegrityViolationException 처리로 동시성 안전성 강화 - 테스트 데이터 userId를 10자 이하로 수정하여 데이터 truncation 에러 해결 Co-authored-by: Cursor --- .../java/com/loopers/domain/user/UserModel.java | 6 +++--- .../java/com/loopers/domain/user/UserService.java | 9 +++++++-- .../domain/user/UserServiceIntegrationTest.java | 13 ++++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index df127200..0e7d649f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -20,13 +20,13 @@ public class UserModel extends BaseEntity { @Column(name = "user_id", nullable = false, unique = true, length = 10) private String userId; - @Column(name = "email", nullable = false) + @Column(name = "email", nullable = false, length = 255) private String email; - @Column(name = "birth_date", nullable = false) + @Column(name = "birth_date", nullable = false, length = 10) private String birthDate; - @Column(name = "encrypted_password", nullable = false) + @Column(name = "encrypted_password", nullable = false, length = 255) private String encryptedPassword; @Enumerated(EnumType.STRING) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 3f837326..949230a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -2,6 +2,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,7 +27,11 @@ public UserModel signUp( throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 사용자 ID입니다: " + userId); } - UserModel user = UserModel.create(userId, email, birthDate, password, gender); - return userRepository.save(user); + try { + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + return userRepository.save(user); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 사용자 ID입니다: " + userId); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index 288852ad..81ddfbd2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -2,6 +2,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -9,6 +10,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -21,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest +@Import(MySqlTestContainersConfig.class) class UserServiceIntegrationTest { @Autowired @@ -47,7 +50,7 @@ class SignUp { @Test void signUp_withDuplicateUserId_shouldThrowConflictException() { // given - String duplicateUserId = "duplicate01"; + String duplicateUserId = "duplicate1"; Email email1 = new Email("user1@example.com"); BirthDate birthDate1 = new BirthDate("1990-01-15"); Password password1 = Password.of("Pass1234!", birthDate1); @@ -73,7 +76,7 @@ void signUp_withDuplicateUserId_shouldThrowConflictException() { @Test void signUp_withConcurrentSameUserId_shouldOnlyOneSucceed() throws InterruptedException { // given - String userId = "concurrent01"; + String userId = "concurrent"; int threadCount = 5; CountDownLatch latch = new CountDownLatch(threadCount); ExecutorService executorService = Executors.newFixedThreadPool(threadCount); @@ -114,7 +117,7 @@ void signUp_withConcurrentSameUserId_shouldOnlyOneSucceed() throws InterruptedEx @Test void signUp_shouldCallRepositorySave() { // given - String userId = "testuser01"; + String userId = "testuser1"; Email email = new Email("test@example.com"); BirthDate birthDate = new BirthDate("1990-01-15"); Password password = Password.of("SecurePass1!", birthDate); @@ -133,7 +136,7 @@ void signUp_shouldCallRepositorySave() { @Test void signUp_shouldStoreEncryptedPassword() { // given - String userId = "testuser02"; + String userId = "testuser2"; String rawPassword = "RawPassword123!"; Email email = new Email("test2@example.com"); BirthDate birthDate = new BirthDate("1992-06-10"); @@ -152,7 +155,7 @@ void signUp_shouldStoreEncryptedPassword() { @Test void signUp_shouldEncryptPasswordWithBCrypt() { // given - String userId = "testuser03"; + String userId = "testuser3"; String rawPassword = "VerifyPass456!"; Email email = new Email("test3@example.com"); BirthDate birthDate = new BirthDate("1988-12-25"); From 6400f01966ffa699158fd5c4057404e1b5caee64 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 14:52:55 +0900 Subject: [PATCH 10/32] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20Layer=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20E2E?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E2E 테스트: validation, 성공 케이스, 중복 ID 처리 - Application Layer: UserFacade (VO 변환 및 orchestration), UserInfo (응답 DTO) - Interface Layer: UserV1Controller, UserV1Dto, UserV1ApiSpec - Validation 예외 처리: MethodArgumentNotValidException 핸들러 추가 - 모든 테스트 통과: Unit + Integration + E2E Co-authored-by: Cursor --- .../loopers/application/user/UserFacade.java | 30 ++++ .../loopers/application/user/UserInfo.java | 19 +++ .../interfaces/api/ApiControllerAdvice.java | 9 + .../interfaces/api/user/UserV1ApiSpec.java | 20 +++ .../interfaces/api/user/UserV1Controller.java | 37 ++++ .../interfaces/api/user/UserV1Dto.java | 44 +++++ .../interfaces/api/user/UserV1ApiE2ETest.java | 160 ++++++++++++++++++ 7 files changed, 319 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 00000000..03503c13 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,30 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.*; +import org.springframework.stereotype.Service; + +@Service +public class UserFacade { + + private final UserService userService; + + public UserFacade(UserService userService) { + this.userService = userService; + } + + public UserInfo signUp( + String userId, + String password, + String email, + String birthDate, + String genderValue + ) { + Email emailVO = new Email(email); + BirthDate birthDateVO = new BirthDate(birthDate); + Password passwordVO = Password.of(password, birthDateVO); + Gender gender = Gender.from(genderValue); + + UserModel user = userService.signUp(userId, emailVO, birthDateVO, passwordVO, gender); + return UserInfo.from(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 00000000..b4fc7fe0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,19 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.UserModel; + +public record UserInfo( + String userId, + String email, + String birthDate, + String gender +) { + public static UserInfo from(UserModel user) { + return new UserInfo( + user.getUserId(), + user.getEmail(), + user.getBirthDate(), + user.getGender().name() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c..cd30bb4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -8,6 +8,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +48,13 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + FieldError fieldError = e.getBindingResult().getFieldError(); + String message = fieldError != null ? fieldError.getDefaultMessage() : "입력값 검증에 실패했습니다."; + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 00000000..0d3af8b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "User V1 API", description = "사용자 관리 API") +public interface UserV1ApiSpec { + + @Operation( + summary = "회원가입", + description = "새로운 사용자를 등록합니다." + ) + ApiResponse signUp( + @Schema(description = "회원가입 요청 정보") + @Valid UserV1Dto.SignUpRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 00000000..532603a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/customer/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + public UserV1Controller(UserFacade userFacade) { + this.userFacade = userFacade; + } + + @PostMapping("/sign-up") + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp( + @Valid @RequestBody UserV1Dto.SignUpRequest request + ) { + UserInfo userInfo = userFacade.signUp( + request.userId(), + request.password(), + request.email(), + request.birthDate(), + request.gender() + ); + + UserV1Dto.SignUpResponse response = UserV1Dto.SignUpResponse.from(userInfo); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 00000000..de5e56b3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,44 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public class UserV1Dto { + + public record SignUpRequest( + @NotBlank(message = "사용자 ID는 필수입니다.") + @Pattern(regexp = "^[a-zA-Z0-9]{1,10}$", message = "사용자 ID는 영문자와 숫자로만 구성되며 최대 10자입니다.") + String userId, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password, + + @NotBlank(message = "이메일은 필수입니다.") + String email, + + @NotBlank(message = "생년월일은 필수입니다.") + @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "생년월일 형식은 yyyy-MM-dd 이어야 합니다.") + String birthDate, + + @NotBlank(message = "성별은 필수입니다.") + String gender + ) { + } + + public record SignUpResponse( + String userId, + String email, + String birthDate, + String gender + ) { + public static SignUpResponse from(UserInfo userInfo) { + return new SignUpResponse( + userInfo.userId(), + userInfo.email(), + userInfo.birthDate(), + userInfo.gender() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java new file mode 100644 index 00000000..5ba6b645 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java @@ -0,0 +1,160 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.testcontainers.MySqlTestContainersConfig; +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; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static com.loopers.interfaces.api.ApiResponse.Metadata.Result; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_SIGN_UP = "/customer/v1/users/sign-up"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /customer/v1/users/sign-up - 회원가입") + @Nested + class SignUp { + + @DisplayName("gender가 누락되면, 400 Bad Request를 반환한다.") + @Test + void signUp_withoutGender_shouldReturnBadRequest() { + // given + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + "testuser1", + "SecurePass1!", + "test@example.com", + "1990-01-15", + null // gender 누락 + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) + ); + } + + @DisplayName("userId가 누락되면, 400 Bad Request를 반환한다.") + @Test + void signUp_withoutUserId_shouldReturnBadRequest() { + // given + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + null, // userId 누락 + "SecurePass1!", + "test@example.com", + "1990-01-15", + "MALE" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) + ); + } + + @DisplayName("유효한 회원가입 요청 시, 201 Created와 생성된 사용자 정보를 반환한다.") + @Test + void signUp_withValidRequest_shouldReturnCreated() { + // given + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + "testuser1", + "SecurePass1!", + "test@example.com", + "1990-01-15", + "MALE" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.SUCCESS), + () -> assertThat(response.getBody().data().userId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("1990-01-15"), + () -> assertThat(response.getBody().data().gender()).isEqualTo("MALE") + ); + } + + @DisplayName("이미 존재하는 userId로 가입 시, 409 Conflict를 반환한다.") + @Test + void signUp_withExistingUserId_shouldReturnConflict() { + // given + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + "testuser1", + "SecurePass1!", + "test@example.com", + "1990-01-15", + "MALE" + ); + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference>() {}); + + // when - 동일한 userId로 다시 가입 시도 + UserV1Dto.SignUpRequest duplicateRequest = new UserV1Dto.SignUpRequest( + "testuser1", + "DiffPass2!", + "different@example.com", + "1995-05-20", + "FEMALE" + ); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(duplicateRequest), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL), + () -> assertThat(response.getBody().meta().errorCode()).isEqualTo("Conflict") + ); + } + } +} From 246236c3a8de3cd0f907bbb8b8fabcb17395921b Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:14:23 +0900 Subject: [PATCH 11/32] =?UTF-8?q?test:=20NameMaskingPolicy=20Unit=20Tests?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - null, 빈 문자열, 공백, 1글자 이름 예외 케이스 - 2글자, 3글자 이상 한글 이름 마스킹 케이스 - 영문 이름 마스킹 케이스 Co-authored-by: Cursor --- .../domain/user/NameMaskingPolicyTest.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/NameMaskingPolicyTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/NameMaskingPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/NameMaskingPolicyTest.java new file mode 100644 index 00000000..7b896032 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/NameMaskingPolicyTest.java @@ -0,0 +1,78 @@ +package com.loopers.domain.user; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class NameMaskingPolicyTest { + + @DisplayName("null 이름을 마스킹하면 예외가 발생한다.") + @Test + void mask_withNull_shouldThrowException() { + // when & then + assertThrows(IllegalArgumentException.class, () -> { + NameMaskingPolicy.mask(null); + }); + } + + @DisplayName("빈 문자열 이름을 마스킹하면 예외가 발생한다.") + @Test + void mask_withEmptyString_shouldThrowException() { + // when & then + assertThrows(IllegalArgumentException.class, () -> { + NameMaskingPolicy.mask(""); + }); + } + + @DisplayName("공백만 있는 이름을 마스킹하면 예외가 발생한다.") + @Test + void mask_withWhitespace_shouldThrowException() { + // when & then + assertThrows(IllegalArgumentException.class, () -> { + NameMaskingPolicy.mask(" "); + }); + } + + @DisplayName("한 글자 이름을 마스킹하면 예외가 발생한다.") + @Test + void mask_withSingleCharacter_shouldThrowException() { + // when & then + assertThrows(IllegalArgumentException.class, () -> { + NameMaskingPolicy.mask("김"); + }); + } + + @DisplayName("두 글자 이름은 마지막 글자가 마스킹된다.") + @Test + void mask_withTwoCharacters_shouldMaskLastCharacter() { + // when + String masked = NameMaskingPolicy.mask("김철"); + + // then + assertThat(masked).isEqualTo("김*"); + } + + @DisplayName("세 글자 이상 이름은 마지막 글자만 마스킹된다.") + @Test + void mask_withThreeOrMoreCharacters_shouldMaskLastCharacter() { + // when + String masked = NameMaskingPolicy.mask("홍길동"); + + // then + assertThat(masked).isEqualTo("홍길*"); + } + + @DisplayName("영문 이름도 마지막 글자가 마스킹된다.") + @Test + void mask_withEnglishName_shouldMaskLastCharacter() { + // when + String masked1 = NameMaskingPolicy.mask("Alan"); + String masked2 = NameMaskingPolicy.mask("Jo"); + + // then + assertThat(masked1).isEqualTo("Ala*"); + assertThat(masked2).isEqualTo("J*"); + } +} From 7bb0ac0b24d6349be389a54b2f6df6918276f661 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:14:31 +0900 Subject: [PATCH 12/32] =?UTF-8?q?feat:=20NameMaskingPolicy=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이름 마지막 글자를 *로 마스킹 - 입력 검증: null, 빈 문자열, 1글자 이하 예외 처리 Co-authored-by: Cursor --- .../domain/user/NameMaskingPolicy.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/NameMaskingPolicy.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/NameMaskingPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/NameMaskingPolicy.java new file mode 100644 index 00000000..d261e4c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/NameMaskingPolicy.java @@ -0,0 +1,25 @@ +package com.loopers.domain.user; + +public class NameMaskingPolicy { + + public static String mask(String name) { + validateName(name); + + String trimmed = name.trim(); + if (trimmed.length() < 2) { + throw new IllegalArgumentException("이름은 최소 2자 이상이어야 합니다."); + } + + return trimmed.substring(0, trimmed.length() - 1) + "*"; + } + + private static void validateName(String name) { + if (name == null) { + throw new IllegalArgumentException("이름은 null일 수 없습니다."); + } + + if (name.isBlank()) { + throw new IllegalArgumentException("이름은 비어있을 수 없습니다."); + } + } +} From 63fcd500c10827dc94b4e4527fd086757f742bb3 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:14:39 +0900 Subject: [PATCH 13/32] =?UTF-8?q?test:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Integration=20Tests=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 존재하지 않는 userId 조회 시 null 반환 - 존재하는 userId 조회 시 사용자 정보 반환 - 이름 마스킹 적용 확인 Co-authored-by: Cursor --- .../user/UserServiceIntegrationTest.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index 81ddfbd2..71024c2a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.application.user.UserInfo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.testcontainers.MySqlTestContainersConfig; @@ -170,4 +171,64 @@ void signUp_shouldEncryptPasswordWithBCrypt() { assertThat(matches).isTrue(); } } + + @DisplayName("내 정보를 조회할 때, ") + @Nested + class GetMyInfo { + + @DisplayName("존재하지 않는 사용자 ID로 조회하면, null을 반환한다.") + @Test + void getMyInfo_withNonExistentUserId_shouldReturnNull() { + // given + String nonExistentUserId = "nouser"; + + // when + UserInfo userInfo = userService.getMyInfo(nonExistentUserId); + + // then + assertThat(userInfo).isNull(); + } + + @DisplayName("존재하는 사용자 ID로 조회하면, 사용자 정보를 반환한다.") + @Test + void getMyInfo_withExistingUserId_shouldReturnUserInfo() { + // given + String userId = "testuser1"; + Email email = new Email("test@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("SecurePass1!", birthDate); + Gender gender = Gender.MALE; + + userService.signUp(userId, email, birthDate, password, gender); + + // when + UserInfo userInfo = userService.getMyInfo(userId); + + // then + assertThat(userInfo).isNotNull(); + assertThat(userInfo.userId()).isEqualTo(userId); + assertThat(userInfo.email()).isEqualTo("test@example.com"); + assertThat(userInfo.birthDate()).isEqualTo("1990-01-15"); + assertThat(userInfo.gender()).isEqualTo("MALE"); + } + + @DisplayName("조회된 사용자 정보의 이름은 userId를 마스킹한 값이다.") + @Test + void getMyInfo_shouldReturnMaskedName() { + // given + String userId = "johnsmith"; + Email email = new Email("john@example.com"); + BirthDate birthDate = new BirthDate("1992-06-10"); + Password password = Password.of("Password2!", birthDate); + Gender gender = Gender.MALE; + + userService.signUp(userId, email, birthDate, password, gender); + + // when + UserInfo userInfo = userService.getMyInfo(userId); + + // then + assertThat(userInfo.name()).isEqualTo("johnsmit*"); + } + } } From a734137020fcb96839c22355ecc551f6fcc43b01 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:14:47 +0900 Subject: [PATCH 14/32] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Service=20=EB=B0=8F=20Facade=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserService.getMyInfo(): userId로 사용자 조회 후 UserInfo 반환 - UserInfo: name 필드 추가 (마스킹된 userId) - UserFacade.getMyInfo(): Application Layer orchestration Co-authored-by: Cursor --- .../java/com/loopers/application/user/UserFacade.java | 4 ++++ .../main/java/com/loopers/application/user/UserInfo.java | 3 +++ .../main/java/com/loopers/domain/user/UserService.java | 8 ++++++++ 3 files changed, 15 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 03503c13..f00b7c9e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -27,4 +27,8 @@ public UserInfo signUp( UserModel user = userService.signUp(userId, emailVO, birthDateVO, passwordVO, gender); return UserInfo.from(user); } + + public UserInfo getMyInfo(String userId) { + return userService.getMyInfo(userId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index b4fc7fe0..148e2b16 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -1,9 +1,11 @@ package com.loopers.application.user; +import com.loopers.domain.user.NameMaskingPolicy; import com.loopers.domain.user.UserModel; public record UserInfo( String userId, + String name, String email, String birthDate, String gender @@ -11,6 +13,7 @@ public record UserInfo( public static UserInfo from(UserModel user) { return new UserInfo( user.getUserId(), + NameMaskingPolicy.mask(user.getUserId()), user.getEmail(), user.getBirthDate(), user.getGender().name() diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 949230a6..42771eba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.application.user.UserInfo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.springframework.dao.DataIntegrityViolationException; @@ -34,4 +35,11 @@ public UserModel signUp( throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 사용자 ID입니다: " + userId); } } + + @Transactional(readOnly = true) + public UserInfo getMyInfo(String userId) { + return userRepository.findByUserId(userId) + .map(UserInfo::from) + .orElse(null); + } } From 6cb81b6897c750a55cdc78dc562ccc0cac1d3b46 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:14:55 +0900 Subject: [PATCH 15/32] =?UTF-8?q?test:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20E2E=20Tests=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - X-Loopers-LoginId 헤더 누락 시 401 UNAUTHORIZED - 존재하지 않는 userId 조회 시 404 NOT_FOUND - 유효한 요청 시 200 OK 및 마스킹된 정보 반환 Co-authored-by: Cursor --- .../interfaces/api/user/UserV1ApiE2ETest.java | 75 ++++++++++++++++++- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java index 5ba6b645..9ed989c8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java @@ -12,10 +12,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.context.annotation.Import; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; import static com.loopers.interfaces.api.ApiResponse.Metadata.Result; import static org.assertj.core.api.Assertions.assertThat; @@ -26,6 +23,7 @@ class UserV1ApiE2ETest { private static final String ENDPOINT_SIGN_UP = "/customer/v1/users/sign-up"; + private static final String ENDPOINT_MY_INFO = "/customer/v1/users/me"; private final TestRestTemplate testRestTemplate; private final DatabaseCleanUp databaseCleanUp; @@ -157,4 +155,73 @@ void signUp_withExistingUserId_shouldReturnConflict() { ); } } + + @DisplayName("GET /customer/v1/users/me - 내 정보 조회") + @Nested + class GetMyInfo { + + @DisplayName("로그인 헤더 없이 요청하면, 401 Unauthorized를 반환한다.") + @Test + void getMyInfo_withoutLoginHeader_shouldReturnUnauthorized() { + // given - 헤더 없음 + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("존재하지 않는 사용자 ID로 조회하면, 404 Not Found를 반환한다.") + @Test + void getMyInfo_withNonExistentUserId_shouldReturnNotFound() { + // given + String nonExistentUserId = "nouser"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", nonExistentUserId); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("유효한 요청 시, 200 OK와 마스킹된 사용자 정보를 반환한다.") + @Test + void getMyInfo_withValidRequest_shouldReturnMaskedUserInfo() { + // given - 먼저 회원가입 + UserV1Dto.SignUpRequest signUpRequest = new UserV1Dto.SignUpRequest( + "johnsmith", + "SecurePass1!", + "john@example.com", + "1990-01-15", + "MALE" + ); + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>() {}); + + // when - 내 정보 조회 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "johnsmith"); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.SUCCESS), + () -> assertThat(response.getBody().data().userId()).isEqualTo("johnsmith"), + () -> assertThat(response.getBody().data().name()).isEqualTo("johnsmit*"), + () -> assertThat(response.getBody().data().email()).isEqualTo("john@example.com"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("1990-01-15"), + () -> assertThat(response.getBody().data().gender()).isEqualTo("MALE") + ); + } + } } From a90b18403ad8e0813ac1a6a97302067e9e215272 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:15:02 +0900 Subject: [PATCH 16/32] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20Layer=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /customer/v1/users/me 엔드포인트 - X-Loopers-LoginId 헤더 인증 - MyInfoResponse DTO 추가 - OpenAPI 명세 추가 Co-authored-by: Cursor --- .../interfaces/api/user/UserV1ApiSpec.java | 10 ++++++++++ .../interfaces/api/user/UserV1Controller.java | 20 +++++++++++++++++++ .../interfaces/api/user/UserV1Dto.java | 18 +++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index 0d3af8b1..425509c2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -2,6 +2,7 @@ import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -17,4 +18,13 @@ ApiResponse signUp( @Schema(description = "회원가입 요청 정보") @Valid UserV1Dto.SignUpRequest request ); + + @Operation( + summary = "내 정보 조회", + description = "로그인한 사용자의 정보를 조회합니다." + ) + ApiResponse getMyInfo( + @Parameter(description = "로그인 사용자 ID", required = true) + String loginId + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 532603a3..634dfdcd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -3,6 +3,8 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @@ -34,4 +36,22 @@ public ApiResponse signUp( UserV1Dto.SignUpResponse response = UserV1Dto.SignUpResponse.from(userInfo); return ApiResponse.success(response); } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader(value = "X-Loopers-LoginId", required = false) String loginId + ) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "로그인이 필요합니다."); + } + + UserInfo userInfo = userFacade.getMyInfo(loginId); + if (userInfo == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다: " + loginId); + } + + UserV1Dto.MyInfoResponse response = UserV1Dto.MyInfoResponse.from(userInfo); + return ApiResponse.success(response); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index de5e56b3..085adfa5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -41,4 +41,22 @@ public static SignUpResponse from(UserInfo userInfo) { ); } } + + public record MyInfoResponse( + String userId, + String name, + String email, + String birthDate, + String gender + ) { + public static MyInfoResponse from(UserInfo userInfo) { + return new MyInfoResponse( + userInfo.userId(), + userInfo.name(), + userInfo.email(), + userInfo.birthDate(), + userInfo.gender() + ); + } + } } From 6b95e5fd77c70103127329ab6839ec6183269c33 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:15:12 +0900 Subject: [PATCH 17/32] =?UTF-8?q?feat:=20ErrorType=EC=97=90=20UNAUTHORIZED?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 401 인증 필요 에러 처리를 위해 추가 - X-Loopers-LoginId 헤더 누락 시 사용 Co-authored-by: Cursor --- .../src/main/java/com/loopers/support/error/ErrorType.java | 1 + 1 file changed, 1 insertion(+) 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..bc9fb4c7 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 @@ -10,6 +10,7 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증이 필요합니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); From c3e7e5aa64ead95aaf30746e25b09fca3666e821 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:20:17 +0900 Subject: [PATCH 18/32] =?UTF-8?q?test:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Integration=20Tests=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 존재하지 않는 userId 조회 시 null 반환 - 존재하는 userId 조회 시 포인트 반환 (기본값 0L) Co-authored-by: Cursor --- .../user/UserServiceIntegrationTest.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index 71024c2a..deeb0098 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -231,4 +231,42 @@ void getMyInfo_shouldReturnMaskedName() { assertThat(userInfo.name()).isEqualTo("johnsmit*"); } } + + @DisplayName("포인트를 조회할 때, ") + @Nested + class GetPoints { + + @DisplayName("존재하지 않는 사용자 ID로 조회하면, null을 반환한다.") + @Test + void getPoints_withNonExistentUserId_shouldReturnNull() { + // given + String nonExistentUserId = "nouser"; + + // when + Long points = userService.getPoints(nonExistentUserId); + + // then + assertThat(points).isNull(); + } + + @DisplayName("존재하는 사용자 ID로 조회하면, 포인트를 반환한다.") + @Test + void getPoints_withExistingUserId_shouldReturnPoints() { + // given + String userId = "testuser1"; + Email email = new Email("test@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("SecurePass1!", birthDate); + Gender gender = Gender.MALE; + + userService.signUp(userId, email, birthDate, password, gender); + + // when + Long points = userService.getPoints(userId); + + // then + assertThat(points).isNotNull(); + assertThat(points).isEqualTo(0L); + } + } } From e4ee870899f07caf286531da2a58b7e9a62cdb42 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:21:23 +0900 Subject: [PATCH 19/32] =?UTF-8?q?feat:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Service=20=EB=B0=8F=20Facade=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserService.getPoints(): userId로 포인트 조회 - PointsInfo: userId와 points를 포함하는 DTO - UserFacade.getPoints(): Application Layer orchestration Co-authored-by: Cursor --- .../java/com/loopers/application/user/PointsInfo.java | 7 +++++++ .../java/com/loopers/application/user/UserFacade.java | 8 ++++++++ .../main/java/com/loopers/domain/user/UserService.java | 7 +++++++ 3 files changed, 22 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/PointsInfo.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/PointsInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/PointsInfo.java new file mode 100644 index 00000000..a1150e1b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/PointsInfo.java @@ -0,0 +1,7 @@ +package com.loopers.application.user; + +public record PointsInfo( + String userId, + Long points +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index f00b7c9e..71de2064 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -31,4 +31,12 @@ public UserInfo signUp( public UserInfo getMyInfo(String userId) { return userService.getMyInfo(userId); } + + public PointsInfo getPoints(String userId) { + Long points = userService.getPoints(userId); + if (points == null) { + return null; + } + return new PointsInfo(userId, points); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 42771eba..9fbfc69e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -42,4 +42,11 @@ public UserInfo getMyInfo(String userId) { .map(UserInfo::from) .orElse(null); } + + @Transactional(readOnly = true) + public Long getPoints(String userId) { + return userRepository.findByUserId(userId) + .map(UserModel::getPoints) + .orElse(null); + } } From 6183a40b4ced1b0c5c0dfc2479ccd57bfa93e706 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:22:16 +0900 Subject: [PATCH 20/32] =?UTF-8?q?test:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20E2E=20Tests=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - X-USER-ID 헤더 누락 시 400 BAD_REQUEST - 존재하지 않는 userId 조회 시 404 NOT_FOUND - 유효한 요청 시 200 OK 및 포인트 정보 반환 Co-authored-by: Cursor --- .../interfaces/api/user/UserV1ApiE2ETest.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java index 9ed989c8..2768c44f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java @@ -24,6 +24,7 @@ class UserV1ApiE2ETest { private static final String ENDPOINT_SIGN_UP = "/customer/v1/users/sign-up"; private static final String ENDPOINT_MY_INFO = "/customer/v1/users/me"; + private static final String ENDPOINT_POINTS = "/customer/v1/users/me/points"; private final TestRestTemplate testRestTemplate; private final DatabaseCleanUp databaseCleanUp; @@ -224,4 +225,75 @@ void getMyInfo_withValidRequest_shouldReturnMaskedUserInfo() { ); } } + + @DisplayName("GET /customer/v1/users/me/points - 포인트 조회") + @Nested + class GetPoints { + + @DisplayName("X-USER-ID 헤더가 없으면, 400 Bad Request를 반환한다.") + @Test + void getPoints_withoutUserIdHeader_shouldReturnBadRequest() { + // given + HttpHeaders headers = new HttpHeaders(); + // X-USER-ID 헤더 누락 + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) + ); + } + + @DisplayName("존재하지 않는 사용자 ID로 조회하면, 404 Not Found를 반환한다.") + @Test + void getPoints_withNonExistentUserId_shouldReturnNotFound() { + // given + String nonExistentUserId = "nouser"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", nonExistentUserId); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("유효한 요청 시, 200 OK와 포인트 정보를 반환한다.") + @Test + void getPoints_withValidRequest_shouldReturnPoints() { + // given - 먼저 회원가입 + UserV1Dto.SignUpRequest signUpRequest = new UserV1Dto.SignUpRequest( + "testuser1", + "SecurePass1!", + "test@example.com", + "1990-01-15", + "MALE" + ); + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>() {}); + + // when - 포인트 조회 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", "testuser1"); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.SUCCESS), + () -> assertThat(response.getBody().data().userId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().points()).isEqualTo(0L) + ); + } + } } From efe4d22a0f2b714bf528679dcbf6f77eb6029795 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:23:49 +0900 Subject: [PATCH 21/32] =?UTF-8?q?feat:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20Layer=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /customer/v1/users/me/points 엔드포인트 - X-USER-ID 헤더 인증 - PointsResponse DTO 추가 - OpenAPI 명세 추가 Co-authored-by: Cursor --- .../interfaces/api/user/UserV1ApiSpec.java | 9 +++++++++ .../interfaces/api/user/UserV1Controller.java | 19 +++++++++++++++++++ .../interfaces/api/user/UserV1Dto.java | 13 +++++++++++++ 3 files changed, 41 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index 425509c2..839293f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -27,4 +27,13 @@ ApiResponse getMyInfo( @Parameter(description = "로그인 사용자 ID", required = true) String loginId ); + + @Operation( + summary = "포인트 조회", + description = "사용자의 포인트를 조회합니다." + ) + ApiResponse getPoints( + @Parameter(description = "사용자 ID", required = true) + String userId + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 634dfdcd..40ac2a4b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.user; +import com.loopers.application.user.PointsInfo; import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.interfaces.api.ApiResponse; @@ -54,4 +55,22 @@ public ApiResponse getMyInfo( UserV1Dto.MyInfoResponse response = UserV1Dto.MyInfoResponse.from(userInfo); return ApiResponse.success(response); } + + @GetMapping("/me/points") + @Override + public ApiResponse getPoints( + @RequestHeader(value = "X-USER-ID", required = false) String userId + ) { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-USER-ID 헤더가 필요합니다."); + } + + PointsInfo pointsInfo = userFacade.getPoints(userId); + if (pointsInfo == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다: " + userId); + } + + UserV1Dto.PointsResponse response = UserV1Dto.PointsResponse.from(pointsInfo); + return ApiResponse.success(response); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 085adfa5..6baeb63f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.user; +import com.loopers.application.user.PointsInfo; import com.loopers.application.user.UserInfo; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -59,4 +60,16 @@ public static MyInfoResponse from(UserInfo userInfo) { ); } } + + public record PointsResponse( + String userId, + Long points + ) { + public static PointsResponse from(PointsInfo pointsInfo) { + return new PointsResponse( + pointsInfo.userId(), + pointsInfo.points() + ); + } + } } From fc800069aa7341f5f16aaf86fa1b3764160d9ce1 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:31:54 +0900 Subject: [PATCH 22/32] =?UTF-8?q?test:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20Unit=20Tests=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Password.matches(): - 올바른 비밀번호 검증 시 true 반환 - 잘못된 비밀번호 검증 시 false 반환 UserModel.updatePassword(): - 현재 비밀번호 불일치 시 예외 발생 - 새 비밀번호 == 현재 비밀번호 시 예외 발생 - 올바른 입력 시 비밀번호 변경 성공 Co-authored-by: Cursor --- .../com/loopers/domain/user/PasswordTest.java | 38 ++++++++++ .../loopers/domain/user/UserModelTest.java | 72 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java index 4b10704a..0345c0bd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -113,4 +113,42 @@ void encrypt_shouldReturnDifferentValue() { assertThat(encrypted).isNotBlank(); } } + + @DisplayName("비밀번호를 검증할 때, ") + @Nested + class Matches { + + @DisplayName("원본 비밀번호와 암호화된 비밀번호가 일치하면, true를 반환한다.") + @Test + void matches_withCorrectPassword_shouldReturnTrue() { + // given + String rawPassword = "SecurePass1!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of(rawPassword, birthDate); + String encryptedPassword = password.encrypt(); + + // when + boolean matches = Password.matches(rawPassword, encryptedPassword); + + // then + assertThat(matches).isTrue(); + } + + @DisplayName("원본 비밀번호와 암호화된 비밀번호가 일치하지 않으면, false를 반환한다.") + @Test + void matches_withIncorrectPassword_shouldReturnFalse() { + // given + String correctPassword = "SecurePass1!"; + String wrongPassword = "WrongPass2!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of(correctPassword, birthDate); + String encryptedPassword = password.encrypt(); + + // when + boolean matches = Password.matches(wrongPassword, encryptedPassword); + + // then + assertThat(matches).isFalse(); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 6d522ffe..a3c65986 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; class UserModelTest { @@ -89,4 +90,75 @@ void create_shouldStoreEncryptedPassword() { assertThat(user.getEncryptedPassword()).isNotBlank(); } } + + @DisplayName("비밀번호를 변경할 때, ") + @Nested + class UpdatePassword { + + @DisplayName("현재 비밀번호가 일치하지 않으면, IllegalArgumentException이 발생한다.") + @Test + void updatePassword_withIncorrectCurrentPassword_shouldFail() { + // given + String userId = "testuser01"; + Email email = new Email("test@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password currentPassword = Password.of("OldPass123!", birthDate); + Gender gender = Gender.MALE; + + UserModel user = UserModel.create(userId, email, birthDate, currentPassword, gender); + + String wrongCurrentPassword = "WrongPass!"; + Password newPassword = Password.of("NewPass456!", birthDate); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + user.updatePassword(wrongCurrentPassword, newPassword); + }); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, IllegalArgumentException이 발생한다.") + @Test + void updatePassword_withSamePassword_shouldFail() { + // given + String userId = "testuser02"; + Email email = new Email("test2@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + String samePassword = "SamePass123!"; + Password currentPassword = Password.of(samePassword, birthDate); + Gender gender = Gender.MALE; + + UserModel user = UserModel.create(userId, email, birthDate, currentPassword, gender); + + Password newPassword = Password.of(samePassword, birthDate); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + user.updatePassword(samePassword, newPassword); + }); + } + + @DisplayName("올바른 현재 비밀번호와 새 비밀번호가 주어지면, 비밀번호가 변경된다.") + @Test + void updatePassword_withValidPasswords_shouldSuccess() { + // given + String userId = "testuser03"; + Email email = new Email("test3@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + String currentPasswordValue = "OldPass123!"; + Password currentPassword = Password.of(currentPasswordValue, birthDate); + Gender gender = Gender.MALE; + + UserModel user = UserModel.create(userId, email, birthDate, currentPassword, gender); + String oldEncryptedPassword = user.getEncryptedPassword(); + + Password newPassword = Password.of("NewPass456!", birthDate); + + // when + user.updatePassword(currentPasswordValue, newPassword); + + // then + assertThat(user.getEncryptedPassword()).isNotEqualTo(oldEncryptedPassword); + assertThat(Password.matches("NewPass456!", user.getEncryptedPassword())).isTrue(); + } + } } From 6a379143400b93d9c124eb22326be125a776b735 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:33:58 +0900 Subject: [PATCH 23/32] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20Domain=20Layer=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Password.matches(): 비밀번호 검증 메서드 - UserModel.updatePassword(): 비밀번호 변경 메서드 - 현재 비밀번호 검증 - 새 비밀번호 != 현재 비밀번호 검증 - 새 비밀번호 유효성 검증 (생년월일 포함 여부) - 비밀번호 암호화 및 저장 Co-authored-by: Cursor --- .../java/com/loopers/domain/user/Password.java | 4 ++++ .../java/com/loopers/domain/user/UserModel.java | 17 +++++++++++++++++ .../com/loopers/domain/user/UserModelTest.java | 14 ++++++-------- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java index 011d66fb..edc142a7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -56,4 +56,8 @@ private static void validateNotContainsBirthDate(String password, BirthDate birt public String encrypt() { return passwordEncoder.encode(value); } + + public static boolean matches(String rawPassword, String encryptedPassword) { + return passwordEncoder.matches(rawPassword, encryptedPassword); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index 0e7d649f..a089f9c7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -68,4 +68,21 @@ public static UserModel create( 0L ); } + + public void updatePassword(String currentRawPassword, String newRawPassword) { + // 현재 비밀번호 검증 + if (!Password.matches(currentRawPassword, this.encryptedPassword)) { + throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); + } + + // 새 비밀번호가 현재 비밀번호와 동일한지 검증 + if (currentRawPassword.equals(newRawPassword)) { + throw new IllegalArgumentException("새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + // 새 비밀번호 생성 및 암호화 + BirthDate birthDate = new BirthDate(this.birthDate); + Password newPassword = Password.of(newRawPassword, birthDate); + this.encryptedPassword = newPassword.encrypt(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index a3c65986..9e4ae9f0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -108,11 +108,11 @@ void updatePassword_withIncorrectCurrentPassword_shouldFail() { UserModel user = UserModel.create(userId, email, birthDate, currentPassword, gender); String wrongCurrentPassword = "WrongPass!"; - Password newPassword = Password.of("NewPass456!", birthDate); + String newPasswordValue = "NewPass456!"; // when & then assertThrows(IllegalArgumentException.class, () -> { - user.updatePassword(wrongCurrentPassword, newPassword); + user.updatePassword(wrongCurrentPassword, newPasswordValue); }); } @@ -129,11 +129,9 @@ void updatePassword_withSamePassword_shouldFail() { UserModel user = UserModel.create(userId, email, birthDate, currentPassword, gender); - Password newPassword = Password.of(samePassword, birthDate); - // when & then assertThrows(IllegalArgumentException.class, () -> { - user.updatePassword(samePassword, newPassword); + user.updatePassword(samePassword, samePassword); }); } @@ -151,14 +149,14 @@ void updatePassword_withValidPasswords_shouldSuccess() { UserModel user = UserModel.create(userId, email, birthDate, currentPassword, gender); String oldEncryptedPassword = user.getEncryptedPassword(); - Password newPassword = Password.of("NewPass456!", birthDate); + String newPasswordValue = "NewPass456!"; // when - user.updatePassword(currentPasswordValue, newPassword); + user.updatePassword(currentPasswordValue, newPasswordValue); // then assertThat(user.getEncryptedPassword()).isNotEqualTo(oldEncryptedPassword); - assertThat(Password.matches("NewPass456!", user.getEncryptedPassword())).isTrue(); + assertThat(Password.matches(newPasswordValue, user.getEncryptedPassword())).isTrue(); } } } From 39a83699f2b6959bf8761bdde001aa408c1cd497 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:36:47 +0900 Subject: [PATCH 24/32] =?UTF-8?q?test:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20Integration=20Tests=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 존재하지 않는 userId 시 NOT_FOUND - 현재 비밀번호 불일치 시 BAD_REQUEST - 새 비밀번호 == 현재 비밀번호 시 BAD_REQUEST - 올바른 입력 시 비밀번호 변경 성공 Co-authored-by: Cursor --- .../user/UserServiceIntegrationTest.java | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index 71024c2a..8cc2fc98 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -231,4 +231,94 @@ void getMyInfo_shouldReturnMaskedName() { assertThat(userInfo.name()).isEqualTo("johnsmit*"); } } + + @DisplayName("비밀번호를 변경할 때, ") + @Nested + class UpdatePassword { + + @DisplayName("존재하지 않는 사용자 ID로 변경하면, CoreException(NOT_FOUND)이 발생한다.") + @Test + void updatePassword_withNonExistentUserId_shouldThrowNotFoundException() { + // given + String nonExistentUserId = "nouser"; + String currentPassword = "OldPass123!"; + String newPassword = "NewPass456!"; + + // when & then + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updatePassword(nonExistentUserId, currentPassword, newPassword); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, CoreException(BAD_REQUEST)이 발생한다.") + @Test + void updatePassword_withIncorrectCurrentPassword_shouldThrowBadRequestException() { + // given + String userId = "testuser1"; + Email email = new Email("test@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("OldPass123!", birthDate); + Gender gender = Gender.MALE; + + userService.signUp(userId, email, birthDate, password, gender); + + String wrongCurrentPassword = "WrongPass!"; + String newPassword = "NewPass456!"; + + // when & then + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updatePassword(userId, wrongCurrentPassword, newPassword); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, CoreException(BAD_REQUEST)이 발생한다.") + @Test + void updatePassword_withSamePassword_shouldThrowBadRequestException() { + // given + String userId = "testuser2"; + Email email = new Email("test2@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + String samePassword = "SamePass123!"; + Password password = Password.of(samePassword, birthDate); + Gender gender = Gender.MALE; + + userService.signUp(userId, email, birthDate, password, gender); + + // when & then + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updatePassword(userId, samePassword, samePassword); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("올바른 입력으로 비밀번호를 변경하면, 성공한다.") + @Test + void updatePassword_withValidInputs_shouldSuccess() { + // given + String userId = "testuser3"; + Email email = new Email("test3@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + String currentPassword = "OldPass123!"; + Password password = Password.of(currentPassword, birthDate); + Gender gender = Gender.MALE; + + UserModel user = userService.signUp(userId, email, birthDate, password, gender); + String oldEncryptedPassword = user.getEncryptedPassword(); + + String newPassword = "NewPass456!"; + + // when + userService.updatePassword(userId, currentPassword, newPassword); + + // then + UserModel updatedUser = userRepository.findByUserId(userId).orElseThrow(); + assertThat(updatedUser.getEncryptedPassword()).isNotEqualTo(oldEncryptedPassword); + assertThat(Password.matches(newPassword, updatedUser.getEncryptedPassword())).isTrue(); + } + } } From f893008ccf554cbcd703dc100f82c07667cb9be0 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:38:30 +0900 Subject: [PATCH 25/32] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20Service=20=EB=B0=8F=20Facade?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserService.updatePassword(): 비밀번호 변경 서비스 로직 - 사용자 조회 (없으면 NOT_FOUND) - UserModel.updatePassword() 호출 - IllegalArgumentException → BAD_REQUEST 변환 - UserFacade.updatePassword(): Application Layer orchestration Co-authored-by: Cursor --- .../com/loopers/application/user/UserFacade.java | 4 ++++ .../java/com/loopers/domain/user/UserService.java | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index f00b7c9e..730d62bf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -31,4 +31,8 @@ public UserInfo signUp( public UserInfo getMyInfo(String userId) { return userService.getMyInfo(userId); } + + public void updatePassword(String userId, String currentPassword, String newPassword) { + userService.updatePassword(userId, currentPassword, newPassword); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 42771eba..ca69024f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -42,4 +42,16 @@ public UserInfo getMyInfo(String userId) { .map(UserInfo::from) .orElse(null); } + + @Transactional + public void updatePassword(String userId, String currentPassword, String newPassword) { + UserModel user = userRepository.findByUserId(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다: " + userId)); + + try { + user.updatePassword(currentPassword, newPassword); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, e.getMessage()); + } + } } From 62c001cce2ea91dfa7d32fbe57d8828b5c53ed71 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:44:11 +0900 Subject: [PATCH 26/32] =?UTF-8?q?test:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20E2E=20Tests=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - X-Loopers-LoginId 헤더 누락 시 401 UNAUTHORIZED - 존재하지 않는 userId 시 404 NOT_FOUND - 현재 비밀번호 불일치 시 400 BAD_REQUEST - 새 비밀번호 == 현재 비밀번호 시 400 BAD_REQUEST - 유효한 요청 시 200 OK Co-authored-by: Cursor --- .../interfaces/api/user/UserV1ApiE2ETest.java | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java index 9ed989c8..5115ba37 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java @@ -24,6 +24,7 @@ class UserV1ApiE2ETest { private static final String ENDPOINT_SIGN_UP = "/customer/v1/users/sign-up"; private static final String ENDPOINT_MY_INFO = "/customer/v1/users/me"; + private static final String ENDPOINT_PASSWORD = "/customer/v1/users/me/password"; private final TestRestTemplate testRestTemplate; private final DatabaseCleanUp databaseCleanUp; @@ -224,4 +225,150 @@ void getMyInfo_withValidRequest_shouldReturnMaskedUserInfo() { ); } } + + @DisplayName("PATCH /customer/v1/users/me/password - 비밀번호 변경") + @Nested + class UpdatePassword { + + @DisplayName("X-Loopers-LoginId 헤더가 없으면, 401 Unauthorized를 반환한다.") + @Test + void updatePassword_withoutLoginIdHeader_shouldReturnUnauthorized() { + // given + HttpHeaders headers = new HttpHeaders(); + // X-Loopers-LoginId 헤더 누락 + UserV1Dto.UpdatePasswordRequest request = new UserV1Dto.UpdatePasswordRequest( + "OldPass123!", + "NewPass456!" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) + ); + } + + @DisplayName("존재하지 않는 사용자 ID로 변경하면, 404 Not Found를 반환한다.") + @Test + void updatePassword_withNonExistentUserId_shouldReturnNotFound() { + // given + String nonExistentUserId = "nouser"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", nonExistentUserId); + UserV1Dto.UpdatePasswordRequest request = new UserV1Dto.UpdatePasswordRequest( + "OldPass123!", + "NewPass456!" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, 400 Bad Request를 반환한다.") + @Test + void updatePassword_withIncorrectCurrentPassword_shouldReturnBadRequest() { + // given - 먼저 회원가입 + UserV1Dto.SignUpRequest signUpRequest = new UserV1Dto.SignUpRequest( + "testuser1", + "OldPass123!", + "test@example.com", + "1990-01-15", + "MALE" + ); + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>() {}); + + // when - 잘못된 현재 비밀번호로 변경 시도 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser1"); + UserV1Dto.UpdatePasswordRequest request = new UserV1Dto.UpdatePasswordRequest( + "WrongPass!", + "NewPass456!" + ); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) + ); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 Bad Request를 반환한다.") + @Test + void updatePassword_withSamePassword_shouldReturnBadRequest() { + // given - 먼저 회원가입 + String samePassword = "SamePass123!"; + UserV1Dto.SignUpRequest signUpRequest = new UserV1Dto.SignUpRequest( + "testuser2", + samePassword, + "test2@example.com", + "1990-01-15", + "MALE" + ); + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>() {}); + + // when - 같은 비밀번호로 변경 시도 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser2"); + UserV1Dto.UpdatePasswordRequest request = new UserV1Dto.UpdatePasswordRequest( + samePassword, + samePassword + ); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) + ); + } + + @DisplayName("유효한 요청 시, 200 OK를 반환하고 비밀번호가 변경된다.") + @Test + void updatePassword_withValidRequest_shouldSuccess() { + // given - 먼저 회원가입 + UserV1Dto.SignUpRequest signUpRequest = new UserV1Dto.SignUpRequest( + "testuser3", + "OldPass123!", + "test3@example.com", + "1990-01-15", + "MALE" + ); + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>() {}); + + // when - 비밀번호 변경 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser3"); + UserV1Dto.UpdatePasswordRequest request = new UserV1Dto.UpdatePasswordRequest( + "OldPass123!", + "NewPass456!" + ); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.SUCCESS) + ); + } + } } From 570324525059acd570e7ead3262fc00e2bd935fd Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 6 Feb 2026 15:49:06 +0900 Subject: [PATCH 27/32] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20API=20Layer=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /customer/v1/users/me/password 엔드포인트 - X-Loopers-LoginId 헤더 인증 - UpdatePasswordRequest DTO 추가 - OpenAPI 명세 추가 Co-authored-by: Cursor --- .../loopers/interfaces/api/user/UserV1ApiSpec.java | 11 +++++++++++ .../interfaces/api/user/UserV1Controller.java | 14 ++++++++++++++ .../com/loopers/interfaces/api/user/UserV1Dto.java | 9 +++++++++ 3 files changed, 34 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index 425509c2..2581822b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -27,4 +27,15 @@ ApiResponse getMyInfo( @Parameter(description = "로그인 사용자 ID", required = true) String loginId ); + + @Operation( + summary = "비밀번호 변경", + description = "사용자의 비밀번호를 변경합니다." + ) + ApiResponse updatePassword( + @Parameter(description = "로그인 사용자 ID", required = true) + String loginId, + @Schema(description = "비밀번호 변경 요청 정보") + @Valid UserV1Dto.UpdatePasswordRequest request + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 634dfdcd..52a3c901 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -54,4 +54,18 @@ public ApiResponse getMyInfo( UserV1Dto.MyInfoResponse response = UserV1Dto.MyInfoResponse.from(userInfo); return ApiResponse.success(response); } + + @PatchMapping("/me/password") + @Override + public ApiResponse updatePassword( + @RequestHeader(value = "X-Loopers-LoginId", required = false) String loginId, + @Valid @RequestBody UserV1Dto.UpdatePasswordRequest request + ) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "로그인이 필요합니다."); + } + + userFacade.updatePassword(loginId, request.currentPassword(), request.newPassword()); + return ApiResponse.success(null); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 085adfa5..40ab52f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -59,4 +59,13 @@ public static MyInfoResponse from(UserInfo userInfo) { ); } } + + public record UpdatePasswordRequest( + @NotBlank(message = "현재 비밀번호는 필수입니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + String newPassword + ) { + } } From 4f35ce7a620e9f65c12d381f120c5ecf709eb8eb Mon Sep 17 00:00:00 2001 From: Avocado Date: Mon, 9 Feb 2026 01:05:20 +0900 Subject: [PATCH 28/32] =?UTF-8?q?refactor:=20Domain=20VO=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=9B=90=EC=9D=B8=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Email: 로컬 파트(@앞) 빈 문자열 검증 추가 (parts[0].isBlank()) - Password: birthDate null 체크 추가, matches() null 방어 로직 추가 - BirthDate: DateTimeParseException 원인(cause) 보존 - Gender: IllegalArgumentException 원인(cause) 보존 - BirthDateTest: null/blank 및 예외 cause 검증 테스트 추가 - EmailTest: 로컬 파트 공백, 다중 @ 기호 테스트 추가 - PasswordTest: null/blank, birthDate null, matches null, 경계값(8자/16자) 테스트 추가 Co-authored-by: Cursor --- .../com/loopers/domain/user/BirthDate.java | 2 +- .../java/com/loopers/domain/user/Email.java | 4 +- .../java/com/loopers/domain/user/Gender.java | 2 +- .../com/loopers/domain/user/Password.java | 6 ++ .../loopers/domain/user/BirthDateTest.java | 33 +++++++ .../com/loopers/domain/user/EmailTest.java | 24 +++++ .../com/loopers/domain/user/PasswordTest.java | 90 +++++++++++++++++++ 7 files changed, 157 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java index a0e5d204..2f938bec 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -16,7 +16,7 @@ public record BirthDate(String value) { try { date = LocalDate.parse(value, FORMATTER); } catch (DateTimeParseException e) { - throw new IllegalArgumentException("생년월일 형식은 yyyy-MM-dd 이어야 합니다."); + throw new IllegalArgumentException("생년월일 형식은 yyyy-MM-dd 이어야 합니다.", e); } if (date.isAfter(LocalDate.now())) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java index 82cdcc80..146ebc27 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -11,8 +11,8 @@ public record Email(String value) { } String[] parts = value.split("@"); - if (parts.length != 2 || parts[1].isBlank()) { - throw new IllegalArgumentException("이메일 도메인 형식이 올바르지 않습니다."); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다."); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java index 9461afc1..6a7d13ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java @@ -12,7 +12,7 @@ public static Gender from(String value) { try { return Gender.valueOf(value.toUpperCase()); } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("성별은 MALE 또는 FEMALE이어야 합니다."); + throw new IllegalArgumentException("성별은 MALE 또는 FEMALE이어야 합니다.", e); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java index edc142a7..0160ad9c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -16,6 +16,9 @@ private Password(String value) { public static Password of(String rawPassword, BirthDate birthDate) { validateLength(rawPassword); + if (birthDate == null) { + throw new IllegalArgumentException("생년월일은 null일 수 없습니다."); + } validateNotContainsBirthDate(rawPassword, birthDate); return new Password(rawPassword); } @@ -58,6 +61,9 @@ public String encrypt() { } public static boolean matches(String rawPassword, String encryptedPassword) { + if (rawPassword == null || encryptedPassword == null) { + return false; + } return passwordEncoder.matches(rawPassword, encryptedPassword); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java index 420e45ed..d9a0722d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -64,5 +64,38 @@ void create_withToday_shouldSuccess() { // then assertThat(birthDate.value()).isEqualTo(today); } + + @DisplayName("null이면, IllegalArgumentException이 발생한다.") + @Test + void create_withNull_shouldFail() { + // when & then + assertThrows(IllegalArgumentException.class, () -> { + new BirthDate(null); + }); + } + + @DisplayName("빈 문자열이면, IllegalArgumentException이 발생한다.") + @Test + void create_withBlank_shouldFail() { + // when & then + assertThrows(IllegalArgumentException.class, () -> { + new BirthDate(""); + }); + } + + @DisplayName("형식이 잘못되면, 예외의 원인(cause)이 DateTimeParseException이다.") + @Test + void create_withInvalidFormat_shouldPreserveCause() { + // given + String invalidFormat = "1990/01/15"; + + // when + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + new BirthDate(invalidFormat); + }); + + // then + assertThat(exception.getCause()).isInstanceOf(java.time.format.DateTimeParseException.class); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java index 77aae362..0186e8d6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -49,5 +49,29 @@ void create_withValidFormat_shouldSuccess() { // then assertThat(email.value()).isEqualTo(validEmail); } + + @DisplayName("로컬 파트가 비어있으면(@example.com), IllegalArgumentException이 발생한다.") + @Test + void create_withBlankLocalPart_shouldFail() { + // given + String invalidEmail = "@example.com"; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + new Email(invalidEmail); + }); + } + + @DisplayName("@ 기호가 여러 개이면, IllegalArgumentException이 발생한다.") + @Test + void create_withMultipleAtSigns_shouldFail() { + // given + String invalidEmail = "user@domain@com"; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + new Email(invalidEmail); + }); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java index 0345c0bd..69df7be6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -91,6 +91,70 @@ void create_withValidPasswordWithoutBirthDate_shouldSuccess() { // then assertThat(password).isNotNull(); } + + @DisplayName("null이면, IllegalArgumentException이 발생한다.") + @Test + void create_withNull_shouldFail() { + // given + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + Password.of(null, birthDate); + }); + } + + @DisplayName("빈 문자열이면, IllegalArgumentException이 발생한다.") + @Test + void create_withBlank_shouldFail() { + // given + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + Password.of(" ", birthDate); + }); + } + + @DisplayName("생년월일이 null이면, IllegalArgumentException이 발생한다.") + @Test + void create_withNullBirthDate_shouldFail() { + // given + String password = "SecurePass1!"; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + Password.of(password, null); + }); + } + + @DisplayName("정확히 8자이면, 정상적으로 생성된다.") + @Test + void create_withExactly8Characters_shouldSuccess() { + // given + String password = "Pass12!a"; + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when + Password result = Password.of(password, birthDate); + + // then + assertThat(result).isNotNull(); + } + + @DisplayName("정확히 16자이면, 정상적으로 생성된다.") + @Test + void create_withExactly16Characters_shouldSuccess() { + // given + String password = "Pass12!abcdefgh1"; + BirthDate birthDate = new BirthDate("1990-01-15"); + + // when + Password result = Password.of(password, birthDate); + + // then + assertThat(result).isNotNull(); + } } @DisplayName("비밀번호를 암호화할 때, ") @@ -150,5 +214,31 @@ void matches_withIncorrectPassword_shouldReturnFalse() { // then assertThat(matches).isFalse(); } + + @DisplayName("rawPassword가 null이면, false를 반환한다.") + @Test + void matches_withNullRawPassword_shouldReturnFalse() { + // given + String rawPassword = "SecurePass1!"; + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of(rawPassword, birthDate); + String encryptedPassword = password.encrypt(); + + // when + boolean result = Password.matches(null, encryptedPassword); + + // then + assertThat(result).isFalse(); + } + + @DisplayName("encryptedPassword가 null이면, false를 반환한다.") + @Test + void matches_withNullEncryptedPassword_shouldReturnFalse() { + // when + boolean result = Password.matches("SecurePass1!", null); + + // then + assertThat(result).isFalse(); + } } } From bdf7cdeef28c8a67cff29b4f6ed706022f77a94e Mon Sep 17 00:00:00 2001 From: Avocado Date: Mon, 9 Feb 2026 01:08:17 +0900 Subject: [PATCH 29/32] =?UTF-8?q?refactor:=20UserModel=20create/updatePass?= =?UTF-8?q?word=20null=20=EC=95=88=EC=A0=84=EC=84=B1=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserModel.create(): userId null/blank 검증 추가 - UserModel.updatePassword(): currentRawPassword, newRawPassword null/blank 검증 추가 - UserModelTest: create null/blank userId 실패 테스트 추가 - UserModelTest: updatePassword null/blank 입력 실패 테스트 4개 추가 Co-authored-by: Cursor --- .../com/loopers/domain/user/UserModel.java | 10 ++ .../loopers/domain/user/UserModelTest.java | 102 ++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index a089f9c7..add899b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -59,6 +59,9 @@ public static UserModel create( Password password, Gender gender ) { + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("사용자 ID는 비어있을 수 없습니다."); + } return new UserModel( userId, email.value(), @@ -70,6 +73,13 @@ public static UserModel create( } public void updatePassword(String currentRawPassword, String newRawPassword) { + if (currentRawPassword == null || currentRawPassword.isBlank()) { + throw new IllegalArgumentException("현재 비밀번호는 비어있을 수 없습니다."); + } + if (newRawPassword == null || newRawPassword.isBlank()) { + throw new IllegalArgumentException("새 비밀번호는 비어있을 수 없습니다."); + } + // 현재 비밀번호 검증 if (!Password.matches(currentRawPassword, this.encryptedPassword)) { throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 9e4ae9f0..2ac33ac5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -89,6 +89,36 @@ void create_shouldStoreEncryptedPassword() { assertThat(user.getEncryptedPassword()).isNotEqualTo(rawPassword); assertThat(user.getEncryptedPassword()).isNotBlank(); } + + @DisplayName("userId가 null이면, IllegalArgumentException이 발생한다.") + @Test + void create_withNullUserId_shouldFail() { + // given + Email email = new Email("test@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("SecurePass1!", birthDate); + Gender gender = Gender.MALE; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + UserModel.create(null, email, birthDate, password, gender); + }); + } + + @DisplayName("userId가 빈 문자열이면, IllegalArgumentException이 발생한다.") + @Test + void create_withBlankUserId_shouldFail() { + // given + Email email = new Email("test@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("SecurePass1!", birthDate); + Gender gender = Gender.MALE; + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + UserModel.create(" ", email, birthDate, password, gender); + }); + } } @DisplayName("비밀번호를 변경할 때, ") @@ -158,5 +188,77 @@ void updatePassword_withValidPasswords_shouldSuccess() { assertThat(user.getEncryptedPassword()).isNotEqualTo(oldEncryptedPassword); assertThat(Password.matches(newPasswordValue, user.getEncryptedPassword())).isTrue(); } + + @DisplayName("현재 비밀번호가 null이면, IllegalArgumentException이 발생한다.") + @Test + void updatePassword_withNullCurrentPassword_shouldFail() { + // given + String userId = "testuser04"; + Email email = new Email("test4@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("OldPass123!", birthDate); + Gender gender = Gender.MALE; + + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + user.updatePassword(null, "NewPass456!"); + }); + } + + @DisplayName("새 비밀번호가 null이면, IllegalArgumentException이 발생한다.") + @Test + void updatePassword_withNullNewPassword_shouldFail() { + // given + String userId = "testuser05"; + Email email = new Email("test5@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("OldPass123!", birthDate); + Gender gender = Gender.MALE; + + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + user.updatePassword("OldPass123!", null); + }); + } + + @DisplayName("현재 비밀번호가 빈 문자열이면, IllegalArgumentException이 발생한다.") + @Test + void updatePassword_withBlankCurrentPassword_shouldFail() { + // given + String userId = "testuser06"; + Email email = new Email("test6@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("OldPass123!", birthDate); + Gender gender = Gender.MALE; + + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + user.updatePassword(" ", "NewPass456!"); + }); + } + + @DisplayName("새 비밀번호가 빈 문자열이면, IllegalArgumentException이 발생한다.") + @Test + void updatePassword_withBlankNewPassword_shouldFail() { + // given + String userId = "testuser07"; + Email email = new Email("test7@example.com"); + BirthDate birthDate = new BirthDate("1990-01-15"); + Password password = Password.of("OldPass123!", birthDate); + Gender gender = Gender.MALE; + + UserModel user = UserModel.create(userId, email, birthDate, password, gender); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + user.updatePassword("OldPass123!", " "); + }); + } } } From 0450137c630755301f7ec435904a37bf1bac049b Mon Sep 17 00:00:00 2001 From: Avocado Date: Mon, 9 Feb 2026 01:13:47 +0900 Subject: [PATCH 30/32] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20.DS=5FStor?= =?UTF-8?q?e=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5a979af6..c918d975 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,8 @@ out/ ### VS Code ### .vscode/ +### macOS ### +.DS_Store + ### Kotlin ### .kotlin From a7979e6aab81457ca32e86a46e7402af45337ac1 Mon Sep 17 00:00:00 2001 From: Avocado Date: Mon, 9 Feb 2026 21:40:07 +0900 Subject: [PATCH 31/32] =?UTF-8?q?refactor:=20User=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20HTTP=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /customer/v1/users → /api/v1/users 경로 변경 - 회원가입: POST /sign-up → POST (base path) - 비밀번호 변경: PATCH /me/password → PUT /password - user-v1.http API 문서 신규 작성 (13개 시나리오) Co-authored-by: Cursor --- .../interfaces/api/user/UserV1Controller.java | 6 +- http/commerce-api/user-v1.http | 96 +++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 http/commerce-api/user-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 2be1cf41..46c5f9d4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/customer/v1/users") +@RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { private final UserFacade userFacade; @@ -20,7 +20,7 @@ public UserV1Controller(UserFacade userFacade) { this.userFacade = userFacade; } - @PostMapping("/sign-up") + @PostMapping @ResponseStatus(HttpStatus.CREATED) @Override public ApiResponse signUp( @@ -74,7 +74,7 @@ public ApiResponse getPoints( return ApiResponse.success(response); } - @PatchMapping("/me/password") + @PutMapping("/password") @Override public ApiResponse updatePassword( @RequestHeader(value = "X-Loopers-LoginId", required = false) String loginId, diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http new file mode 100644 index 00000000..1a0797ef --- /dev/null +++ b/http/commerce-api/user-v1.http @@ -0,0 +1,96 @@ +회원가입 - 성공 +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "userId": "testuser01", + "password": "SecurePass1!", + "email": "test@example.com", + "birthDate": "1990-01-15", + "gender": "MALE" +} + +회원가입 - 실패 (성별 누락) +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "userId": "testuser02", + "password": "SecurePass1!", + "email": "test2@example.com", + "birthDate": "1990-01-15" +} + +회원가입 - 실패 (비밀번호에 생년월일 포함) +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "userId": "testuser03", + "password": "Pass19900115!", + "email": "test3@example.com", + "birthDate": "1990-01-15", + "gender": "FEMALE" +} + +회원가입 - 실패 (중복 사용자 ID) +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "userId": "testuser01", + "password": "AnotherPass1!", + "email": "another@example.com", + "birthDate": "1995-06-20", + "gender": "MALE" +} + +내 정보 조회 - 성공 +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: testuser01 + +내 정보 조회 - 실패 (인증 헤더 누락) +GET {{commerce-api}}/api/v1/users/me + +내 정보 조회 - 실패 (존재하지 않는 사용자) +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: nonexistent + +비밀번호 변경 - 성공 +PUT {{commerce-api}}/api/v1/users/password +Content-Type: application/json +X-Loopers-LoginId: testuser01 + +{ + "currentPassword": "SecurePass1!", + "newPassword": "NewPass456!" +} + +비밀번호 변경 - 실패 (현재 비밀번호 불일치) +PUT {{commerce-api}}/api/v1/users/password +Content-Type: application/json +X-Loopers-LoginId: testuser01 + +{ + "currentPassword": "WrongPass!", + "newPassword": "NewPass456!" +} + +비밀번호 변경 - 실패 (인증 헤더 누락) +PUT {{commerce-api}}/api/v1/users/password +Content-Type: application/json + +{ + "currentPassword": "SecurePass1!", + "newPassword": "NewPass456!" +} + +비밀번호 변경 - 실패 (새 비밀번호가 현재와 동일) +PUT {{commerce-api}}/api/v1/users/password +Content-Type: application/json +X-Loopers-LoginId: testuser01 + +{ + "currentPassword": "SecurePass1!", + "newPassword": "SecurePass1!" +} From 39df6413f39931908d969965b573bd53672b1e3d Mon Sep 17 00:00:00 2001 From: Avocado Date: Mon, 9 Feb 2026 21:40:12 +0900 Subject: [PATCH 32/32] =?UTF-8?q?test:=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=8F=20HTTP=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔드포인트 상수 /customer/v1 → /api/v1 경로 변경 - 비밀번호 변경 HttpMethod.PATCH → PUT 변경 - DisplayName 경로 표기 업데이트 Co-authored-by: Cursor --- .../interfaces/api/user/UserV1ApiE2ETest.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java index 17c0716c..9a12d6b1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java @@ -22,10 +22,10 @@ @Import(MySqlTestContainersConfig.class) class UserV1ApiE2ETest { - private static final String ENDPOINT_SIGN_UP = "/customer/v1/users/sign-up"; - private static final String ENDPOINT_MY_INFO = "/customer/v1/users/me"; - private static final String ENDPOINT_POINTS = "/customer/v1/users/me/points"; - private static final String ENDPOINT_PASSWORD = "/customer/v1/users/me/password"; + private static final String ENDPOINT_SIGN_UP = "/api/v1/users"; + private static final String ENDPOINT_MY_INFO = "/api/v1/users/me"; + private static final String ENDPOINT_POINTS = "/api/v1/users/me/points"; + private static final String ENDPOINT_PASSWORD = "/api/v1/users/password"; private final TestRestTemplate testRestTemplate; private final DatabaseCleanUp databaseCleanUp; @@ -44,7 +44,7 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("POST /customer/v1/users/sign-up - 회원가입") + @DisplayName("POST /api/v1/users - 회원가입") @Nested class SignUp { @@ -158,7 +158,7 @@ void signUp_withExistingUserId_shouldReturnConflict() { } } - @DisplayName("GET /customer/v1/users/me - 내 정보 조회") + @DisplayName("GET /api/v1/users/me - 내 정보 조회") @Nested class GetMyInfo { @@ -227,7 +227,7 @@ void getMyInfo_withValidRequest_shouldReturnMaskedUserInfo() { } } - @DisplayName("GET /customer/v1/users/me/points - 포인트 조회") + @DisplayName("GET /api/v1/users/me/points - 포인트 조회") @Nested class GetPoints { @@ -298,7 +298,7 @@ void getPoints_withValidRequest_shouldReturnPoints() { } } - @DisplayName("PATCH /customer/v1/users/me/password - 비밀번호 변경") + @DisplayName("PUT /api/v1/users/password - 비밀번호 변경") @Nested class UpdatePassword { @@ -316,7 +316,7 @@ void updatePassword_withoutLoginIdHeader_shouldReturnUnauthorized() { // when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, headers), responseType); // then assertAll( @@ -340,7 +340,7 @@ void updatePassword_withNonExistentUserId_shouldReturnNotFound() { // when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, headers), responseType); // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); @@ -369,7 +369,7 @@ void updatePassword_withIncorrectCurrentPassword_shouldReturnBadRequest() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, headers), responseType); // then assertAll( @@ -402,7 +402,7 @@ void updatePassword_withSamePassword_shouldReturnBadRequest() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, headers), responseType); // then assertAll( @@ -434,7 +434,7 @@ void updatePassword_withValidRequest_shouldSuccess() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + testRestTemplate.exchange(ENDPOINT_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, headers), responseType); // then assertAll(