diff --git a/CHANGELOG.md b/CHANGELOG.md index 162c439..1b5b368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Phase 2B DTO Layer, Input Validation & API Versioning: + - Versioned REST controllers under `/api/v1/users` and `/api/v1/transactions`. + - Added `spring-boot-starter-validation` dependency for Jakarta Validation (`@Valid`, `@NotBlank`, `@Pattern`, `@DecimalMin`, `@Size`, `@Min`, `@Max`). + - Implemented request DTOs: `CreateUserRequest` and `TransferMoneyRequest` with strict validation rules. + - Implemented response DTO records: `UserResponse`, `TransactionResponse`, and generic `PagedResponse` pagination wrapper. + - Added controller web slice tests (`UserControllerTest`, `TransactionControllerTest`) verifying HTTP status codes and input validation enforcement. + - Added `ADR-004` (URI-based API Versioning and DTO Isolation Layer) to `docs/ADR.md`. - Phase 2A Entity Model Hardening & Rich Domain: - Replaced `Double` primitives with `BigDecimal` (`precision = 19, scale = 4`) across `User` and `Transaction` entities. - Implemented Rich Domain methods (`User.debit()`, `User.credit()`) encapsulating balance invariants and state validation. @@ -43,5 +50,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Basic User (`/users`) and Transaction (`/transactions`) REST endpoints. - Spring Data JPA entities (`User`, `Transaction`) and repositories. - In-memory H2 database persistence for local development builds. -- Initial Spring Boot 4.0.6 project configuration with Java 25. +- Initial Spring Boot 4.1.0 project configuration with Java 25. - System Architecture documentation (`docs/ARCHITECTURE.md`), API Specification (`docs/API_SPECIFICATION.md`), and Phased Roadmap (`docs/ROADMAP.md`). diff --git a/README.md b/README.md index 80a7915..343ea24 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Build Java 25 - Spring Boot + Spring Boot License: MIT PRs Welcome

@@ -21,17 +21,20 @@ The project is evolving through a phased implementation roadmap. See the full pl | **Phase 0** | Baseline REST API — User & Transaction CRUD with H2 in-memory storage | ✅ Complete | | **Phase 1** | Project hygiene — Spotless, Checkstyle, GitHub Actions CI | ✅ Complete | | **Phase 2A** | Domain model hardening — `BigDecimal` financials, rich domain methods, audit timestamps | ✅ Complete | -| **Phase 2B+** | DTO layer, validation, error handling, concurrency control, security, observability | 🔄 In Progress | +| **Phase 2B** | DTO layer, Jakarta validation, URI versioning (`/api/v1/`), response records | ✅ Complete | +| **Phase 2C+** | Custom exception handling, RFC 7807 problem details, optimistic locking, security, observability | 🔄 In Progress | --- ## Implemented Features +- **DTO Isolation & API Versioning**: `/api/v1/` URI paths using Java records (`UserResponse`, `TransactionResponse`, `PagedResponse`) and validated request DTOs (`CreateUserRequest`, `TransferMoneyRequest`). +- **Input Validation**: Enforced via Jakarta Validation (`@Valid`, `@NotBlank`, `@Pattern`, `@DecimalMin`, `@Size`, `@Min`, `@Max`). - **Rich Domain Model**: Entities encapsulate domain invariants (`User.debit()`, `User.credit()`) and balance validation. - **Financial Precision**: All monetary values mapped using `BigDecimal` (`precision = 19, scale = 4`) to prevent floating-point rounding errors. - **Entity Hardening**: Audit timestamps (`createdAt`, `updatedAt`), optimistic locking (`@Version`), JPA `@ManyToOne` foreign key constraints, UUID reference IDs, and transaction status/type enums. - **Constructor Injection**: Enforced across all service and controller components for immutability and testability. -- **REST API**: CRUD endpoints for User registration and Transaction creation. +- **REST API**: CRUD endpoints under `/api/v1/users` and `/api/v1/transactions`. - **Layered Architecture**: Controller → Service → Repository pattern with Spring Data JPA. - **In-Memory Database**: H2 with auto-generated schema for zero-dependency local development. - **Code Quality**: Spotless (Eclipse formatter) + Checkstyle enforced at Maven `validate` phase. diff --git a/docs/ADR.md b/docs/ADR.md index 9b0537d..64a151b 100644 --- a/docs/ADR.md +++ b/docs/ADR.md @@ -13,6 +13,7 @@ ADRs document significant technical decisions, along with their context, rationa | [ADR-001](#adr-001-use-bigdecimal-for-all-monetary-values) | Use BigDecimal for all monetary values | 2026-08-01 | Accepted | | [ADR-002](#adr-002-constructor-injection-over-field-injection) | Constructor injection over field injection | 2026-08-01 | Accepted | | [ADR-003](#adr-003-rich-domain-model-over-anemic-domain-model) | Rich Domain Model over Anemic Domain Model | 2026-08-01 | Accepted | +| [ADR-004](#adr-004-uri-based-api-versioning-and-dto-isolation-layer) | URI-based API Versioning and DTO Isolation Layer | 2026-08-01 | Accepted | --- @@ -93,3 +94,32 @@ Rich domain entities encapsulate domain invariants directly within entity bounda - **Positive**: High cohesion, self-validating entities, reusable business logic across multiple services. - **Negative / Trade-offs**: Entities must remain decoupled from infrastructure concerns (repositories, external APIs). - **Risks & Mitigations**: Keep entity methods focused strictly on state invariants; delegate orchestration to domain services. + +--- + +### ADR-004: URI-based API Versioning and DTO Isolation Layer + +**Date**: 2026-08-01 +**Status**: Accepted +**Phase**: Phase 2B + +#### Context & Problem Statement +Exposing JPA entities directly through REST controllers risks over-posting, entity leak, tight coupling of API contracts to database schemas, and backwards-incompatible API breaks whenever internal domain models evolve. Additionally, unversioned API endpoints make contract evolution hazardous for clients. + +#### Considered Options +1. **Direct JPA Entity Exposure (Unversioned)**: Simple initial implementation, but leaks database structure, enables mass assignment vulnerabilities, and lacks contract stability. +2. **Header or Parameter Versioning**: Flexible versioning via HTTP headers (`Accept: application/vnd.payflow.v1+json`), but harder to cache via HTTP reverse proxies and less clear in logs. +3. **Explicit URI Path Versioning (`/api/v1/`) with DTO Isolation**: Explicit `/api/v1/` prefix with dedicated request objects (Java classes with Jakarta Validation) and response records (`UserResponse`, `TransactionResponse`, `PagedResponse`). + +#### Decision Outcome +Chosen Option: **URI Path Versioning (`/api/v1/`) with DTO Isolation** + +##### Rationale +- **Contract Stability**: Isolates REST API contracts from internal JPA entities using Java records for responses and validated DTO classes for requests. +- **Security & Validation**: Enforces exact field constraints (`@NotBlank`, `@Pattern`, `@DecimalMin`, `@Max`) at the API entry point via Jakarta Validation (`@Valid`), preventing malformed requests from reaching business logic. +- **Observability & Caching**: URI versioning (`/api/v1/`) provides clean HTTP proxy caching and transparent log routing across API gateway boundaries. + +#### Consequences +- **Positive**: Strict decoupling between database tables and API responses; robust validation; backward compatibility pathway (`/api/v2/` in future phases). +- **Negative / Trade-offs**: Mapping code required between DTOs and entities (`UserResponse.fromEntity()`). +- **Risks & Mitigations**: Maintain mapping logic in static factory methods on response records to keep controllers clean and performant. diff --git a/docs/API_SPECIFICATION.md b/docs/API_SPECIFICATION.md index 62ef4b5..df2ca7d 100644 --- a/docs/API_SPECIFICATION.md +++ b/docs/API_SPECIFICATION.md @@ -7,9 +7,11 @@ This document details the REST API endpoints, request/response models, input val ## Global Conventions - **API Base Prefix**: All endpoints are versioned and prefixed with `/api/v1`. -- **Content-Type**: All request and response bodies must use `application/json`. -- **Authentication**: Mutation and secure history endpoints require a cryptographically signed JWT token passed via the `Authorization: Bearer ` header. -- **Idempotency**: All mutation write operations (specifically `POST /api/v1/transactions`) require a unique identifier passed in the `Idempotency-Key` header. +- **Content-Type**: All request and response bodies use `application/json`. +- **Monetary Precision**: All monetary values are encoded as standard JSON numbers with up to 4 decimal places (e.g. `100.0000`). +- **Pagination**: Default page size is 10, with a hard maximum of 100 per page (`@Min(1) @Max(100)`). +- **Authentication**: Mutation and secure history endpoints require a cryptographically signed JWT token passed via the `Authorization: Bearer ` header (implemented in Phase 3). +- **Idempotency**: All mutation write operations require a unique identifier passed in the `Idempotency-Key` header (implemented in Phase 4). --- @@ -19,14 +21,15 @@ When an API error occurs (validation error, resource not found, conflict, etc.), ```json { - "type": "https://api.payflow.com/errors/invalid-transfer-amount", + "type": "https://api.payflow.com/errors/invalid-request", "title": "Invalid Request Content", - "status": 422, - "detail": "The transaction amount must be a positive number greater than zero.", - "instance": "/api/v1/transactions", - "timestamp": "2026-06-12T16:59:46Z", + "status": 400, + "detail": "Validation failed for request parameters.", + "instance": "/api/v1/users", + "timestamp": "2026-08-01T16:59:46Z", "errors": { - "amount": "must be greater than 0.0" + "phoneNumber": "Phone number must be exactly 10 digits", + "balance": "Balance must be non-negative" } } ``` @@ -41,11 +44,11 @@ Registers a new client profile with an initial balance. - **HTTP Method**: `POST` - **Path**: `/api/v1/users` - **Authentication**: None (Public Registration) -- **Request Body Validation**: - - `name`: String, required, cannot be blank. Max length 100 characters. - - `phoneNumber`: String, required, must match standard 10-digit format (regex: `^\d{10}$`). - - `upiId`: String, required, must follow UPI address format (regex: `^[\w.-]+@[\w.-]+$`). - - `balance`: Double, optional. Defaults to `0.0`. Must be positive. +- **Request Body DTO (`CreateUserRequest`)**: + - `name`: String, required (`@NotBlank`), max 100 chars (`@Size(max = 100)`). + - `upiId`: String, required (`@NotBlank`), valid UPI format (`@Pattern(regexp = "^[a-zA-Z0-9.\\-_]{2,256}@[a-zA-Z]{2,64}$")`). + - `phoneNumber`: String, required (`@NotBlank`), exactly 10 digits (`@Pattern(regexp = "^\\d{10}$")`). + - `balance`: BigDecimal, required (`@NotNull`), non-negative (`@DecimalMin("0.0")`). #### Request Example ```json @@ -58,13 +61,17 @@ Registers a new client profile with an initial balance. ``` #### Response Example (`201 Created`) +Headers: `Location: /api/v1/users/1` ```json { "userId": 1, "name": "Jane Doe", "upiId": "janedoe@upi", "phoneNumber": "9876543210", - "balance": 1000.00 + "balance": 1000.0000, + "version": 0, + "createdAt": "2026-08-01T16:00:00Z", + "updatedAt": "2026-08-01T16:00:00Z" } ``` @@ -77,10 +84,9 @@ Retrieves a paginated list of registered users. - **Path**: `/api/v1/users` - **Authentication**: None - **Query Parameters**: - - `page`: Integer, optional. Page index (0-based). Default: `0`. - - `size`: Integer, optional. Page size. Default: `10`. - - `sortBy`: String, optional. Column name to sort. Default: `name`. - - `minBalance`: Double, optional. Filter users with balance strictly greater than this amount. + - `page`: Integer, optional. Page index (0-based, `@Min(0)`). Default: `0`. + - `size`: Integer, optional. Page size (`@Min(1) @Max(100)`). Default: `10`. + - `sortBy`: String, optional. Column name to sort. Default: `userId`. #### Response Example (`200 OK`) ```json @@ -91,20 +97,18 @@ Retrieves a paginated list of registered users. "name": "Jane Doe", "upiId": "janedoe@upi", "phoneNumber": "9876543210", - "balance": 1000.00 + "balance": 1000.0000, + "version": 0, + "createdAt": "2026-08-01T16:00:00Z", + "updatedAt": "2026-08-01T16:00:00Z" } ], - "pageable": { - "pageNumber": 0, - "pageSize": 10, - "sort": { - "empty": false, - "sorted": true, - "unsorted": false - } - }, + "page": 0, + "size": 10, + "totalElements": 1, "totalPages": 1, - "totalElements": 1 + "first": true, + "last": true } ``` @@ -124,10 +128,13 @@ Fetches a single user record by its primary database key. "name": "Jane Doe", "upiId": "janedoe@upi", "phoneNumber": "9876543210", - "balance": 1000.00 + "balance": 1000.0000, + "version": 0, + "createdAt": "2026-08-01T16:00:00Z", + "updatedAt": "2026-08-01T16:00:00Z" } ``` -*If not found, returns `404 Not Found` with a standard RFC 7807 error payload.* +*If not found, returns `404 Not Found`.* --- @@ -145,26 +152,26 @@ Fetches a single user record by their unique UPI ID. "name": "John Smith", "upiId": "johnsmith@upi", "phoneNumber": "9876543211", - "balance": 50.00 + "balance": 50.0000, + "version": 0, + "createdAt": "2026-08-01T16:00:00Z", + "updatedAt": "2026-08-01T16:00:00Z" } ``` -*If not found, returns `404 Not Found` with a standard RFC 7807 error payload.* +*If not found, returns `404 Not Found`.* --- ### 5. Create Money Transfer -Executes an atomic transfer of funds between two users. +Executes a fund transfer request. - **HTTP Method**: `POST` - **Path**: `/api/v1/transactions` -- **Required Headers**: - - `Authorization: Bearer ` - - `Idempotency-Key: ` -- **Request Body Validation**: - - `senderUpiId`: String, required, must match the sender's authenticated UPI context. - - `receiverUpiId`: String, required, cannot equal `senderUpiId`. - - `amount`: Double, required, must be strictly greater than `0.0`. - - `note`: String, optional, max 255 characters. +- **Request Body DTO (`TransferMoneyRequest`)**: + - `senderUpiId`: String, required (`@NotBlank`), valid UPI format (`@Pattern(...)`). + - `receiverUpiId`: String, required (`@NotBlank`), valid UPI format (`@Pattern(...)`). + - `amount`: BigDecimal, required (`@NotNull`), minimum `0.01` (`@DecimalMin("0.01")`). + - `note`: String, optional, max 255 characters (`@Size(max = 255)`). #### Request Example ```json @@ -177,118 +184,18 @@ Executes an atomic transfer of funds between two users. ``` #### Response Example (`201 Created`) +Headers: `Location: /api/v1/transactions/1` ```json { - "transactionId": 105, + "transactionId": 1, + "referenceId": "550e8400-e29b-41d4-a716-446655440000", "senderUpiId": "janedoe@upi", "receiverUpiId": "johnsmith@upi", - "amount": 150.00, - "note": "Dinner bill split" -} -``` - ---- - -### 6. Retrieve Transaction Status Check -Queries a single transaction status by its ID. - -- **HTTP Method**: `GET` -- **Path**: `/api/v1/transactions/{id}` -- **Authentication**: None (or authenticated User context) - -#### Response Example (`200 OK`) -```json -{ - "transactionId": 105, - "senderUpiId": "janedoe@upi", - "receiverUpiId": "johnsmith@upi", - "amount": 150.00, + "amount": 150.0000, + "status": "COMPLETED", + "type": "TRANSFER", "note": "Dinner bill split", - "timestamp": "2026-06-12T17:26:13Z" -} -``` - ---- - -### 7. Transaction Refund / Reversal -Performs a database-level refund of a previous transaction. - -- **HTTP Method**: `POST` -- **Path**: `/api/v1/transactions/{id}/refund` -- **Required Headers**: - - `Authorization: Bearer ` - - `Idempotency-Key: ` -- **Request Body**: - - `reason`: String, optional, max 255 characters. - -#### Request Example -```json -{ - "reason": "Duplicate charge on dining bill" -} -``` - -#### Response Example (`201 Created`) -```json -{ - "refundTransactionId": 201, - "originalTransactionId": 105, - "senderUpiId": "johnsmith@upi", - "receiverUpiId": "janedoe@upi", - "amount": 150.00, - "note": "Refund: Duplicate charge on dining bill", - "timestamp": "2026-06-12T17:42:21Z" -} -``` - ---- - -### 8. User Transaction History (Paginated) -Retrieves a paginated list of transaction history (debits and credits) associated with a user, optimized to prevent JPA N+1 select calls. - -- **HTTP Method**: `GET` -- **Path**: `/api/v1/users/{id}/transactions` -- **Authentication**: `Bearer JWT Token` (User must be accessing their own history) -- **Query Parameters**: - - `page`: Integer, optional. Default: `0`. - - `size`: Integer, optional. Default: `20`. - - `type`: String, optional. Filter transactions by type (`DEBIT`, `CREDIT`). - -#### Response Example (`200 OK`) -```json -{ - "content": [ - { - "transactionId": 105, - "type": "DEBIT", - "senderUpiId": "janedoe@upi", - "receiverUpiId": "johnsmith@upi", - "amount": 150.00, - "note": "Dinner bill split", - "timestamp": "2026-06-12T17:26:13Z" - } - ], - "totalPages": 1, - "totalElements": 1 -} -``` - ---- - -### 9. AI Transaction Spend Insights -Retrieves an automated categorization and budget warning insight for a transaction using Spring AI and a Generative AI LLM. - -- **HTTP Method**: `POST` -- **Path**: `/api/v1/transactions/{id}/insights` -- **Authentication**: `Bearer JWT Token` (User must own the transaction) - -#### Response Example (`200 OK`) -```json -{ - "transactionId": 105, - "category": "Food & Dining", - "sentiment": "NEUTRAL", - "insight": "This dining transaction represents 12% of your monthly food budget. You have spent $350 on dining this month, which is on track to stay within your $500 monthly limit." + "createdAt": "2026-08-01T16:05:00Z" } ``` @@ -298,13 +205,8 @@ Retrieves an automated categorization and budget warning insight for a transacti | Code | Status | Trigger Condition | | :--- | :--- | :--- | -| **200** | `OK` | Standard successful read, lookup, or insights generation. | -| **201** | `Created` | Successfully registered a user, executed a transaction, or processed a refund. | -| **400** | `Bad Request` | Malformed JSON payloads, schema mismatch, or reusing an `Idempotency-Key` with a different request payload. | -| **401** | `Unauthorized` | Missing or invalid cryptographically signed JWT token in `Authorization` header. | -| **403** | `Forbidden` | Authenticated actor attempting to perform a transaction or retrieve records of a different user account. | +| **200** | `OK` | Standard successful read or lookup. | +| **201** | `Created` | Successfully registered a user or created a transaction. | +| **400** | `Bad Request` | Validation failure (`@Valid`) or invalid request query params. | | **404** | `Not Found` | User or transaction lookup returned no records. | -| **409** | `Conflict` | Submitting a request with an `Idempotency-Key` that is currently in a `PROCESSING` state. | -| **422** | `Unprocessable Entity` | Schema fields fail validation checks (e.g. invalid phone number syntax, negative transfer amounts, or insufficient account balance). | -| **429** | `Too Many Requests` | Rate limiter threshold exceeded. Client is temporarily rate-limited. | -| **500** | `Internal Error` | Database connection lost, Kafka dispatch failure, or AI Model API network error. | +| **500** | `Internal Error` | Server error. | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9ad19c9..0eb1c5e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -241,7 +241,7 @@ Unit tests focus on isolating individual components and verifying business logic 1. **Controller Layer (MockMVC)**: - Evaluates HTTP serialization, URL routing, request DTO validation constraints (e.g. invalid UPI patterns, blank fields), and custom error mapping to RFC 7807 payloads. - - Tested using Spring's `@WebMvcTest` paired with `@MockBean` (or `@MockitoBean` in newer Spring Boot releases) to stub the service layers, ensuring lightning-fast execution. + - Tested using Spring's `@WebMvcTest` paired with `@MockitoBean` (standard in Spring Boot 4.1.x) to stub the service layers, ensuring lightning-fast execution. 2. **Service Layer (Mockito)**: - Validates business rules, balance invariant checking, and custom exceptions throwing (e.g., `UserNotFoundException` or `InsufficientBalanceException`). - Uses Mockito annotations (`@ExtendWith(MockitoExtension.class)`) to isolate business service operations from Spring lifecycle overhead. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 17261f1..43419e2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,7 +34,7 @@ Dockerfile JDK alignment, GraalVM profile fix, README cleanup. ### 2A — Entity Model & Rich Domain ✅ `BigDecimal` financials, `@Column` constraints, JPA FK relationships, audit timestamps, status/type enums, rich domain methods (`debit`/`credit`), constructor injection. -### 2B — DTO Layer & Validation ⬜ +### 2B — DTO Layer & Validation ✅ Request/response DTOs, Jakarta validation, `/api/v1/` versioning, pagination with max limits. ### 2C — Mappers & API Docs ⬜ diff --git a/pom.xml b/pom.xml index 11a1e0f..365f3af 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.6 + 4.1.0 @@ -17,6 +17,7 @@ 25 + 3.14.1 2.44.2 3.6.0 2.18.0 @@ -25,7 +26,7 @@ org.springframework.boot - spring-boot-starter-web + spring-boot-starter-webmvc @@ -33,6 +34,11 @@ spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-validation + + com.h2database h2 @@ -40,9 +46,15 @@ - org.projectlombok - lombok - provided + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test @@ -54,14 +66,14 @@ org.springframework.boot - spring-boot-starter-actuator + spring-boot-starter-json org.springframework.boot - spring-boot-starter-test - test + spring-boot-starter-actuator + @@ -69,14 +81,6 @@ org.apache.maven.plugins maven-compiler-plugin - - - - org.projectlombok - lombok - - - @@ -134,6 +138,14 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + -Dnet.bytebuddy.experimental=true + + + org.codehaus.mojo versions-maven-plugin diff --git a/src/main/java/com/payflow/controller/TransactionController.java b/src/main/java/com/payflow/controller/TransactionController.java index 8c68cd5..c2077f7 100644 --- a/src/main/java/com/payflow/controller/TransactionController.java +++ b/src/main/java/com/payflow/controller/TransactionController.java @@ -1,15 +1,25 @@ package com.payflow.controller; +import java.net.URI; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import com.payflow.dto.request.TransferMoneyRequest; +import com.payflow.dto.response.TransactionResponse; import com.payflow.entity.Transaction; import com.payflow.service.TransactionService; +import jakarta.validation.Valid; + @RestController -@RequestMapping("/transactions") +@RequestMapping("/api/v1/transactions") +@Validated public class TransactionController { private final TransactionService transactionService; @@ -19,7 +29,11 @@ public TransactionController(TransactionService transactionService) { } @PostMapping - public Transaction sendMoney(@RequestBody Transaction transaction) { - return transactionService.sendMoney(transaction); + public ResponseEntity sendMoney(@Valid @RequestBody TransferMoneyRequest request) { + Transaction createdTransaction = transactionService.sendMoney(request); + TransactionResponse response = TransactionResponse.fromEntity(createdTransaction); + URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}") + .buildAndExpand(createdTransaction.getTransactionId()).toUri(); + return ResponseEntity.created(location).body(response); } } diff --git a/src/main/java/com/payflow/controller/UserController.java b/src/main/java/com/payflow/controller/UserController.java index 2cacabe..81de04c 100644 --- a/src/main/java/com/payflow/controller/UserController.java +++ b/src/main/java/com/payflow/controller/UserController.java @@ -1,21 +1,37 @@ package com.payflow.controller; import java.math.BigDecimal; +import java.net.URI; import java.util.List; -import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import com.payflow.dto.request.CreateUserRequest; +import com.payflow.dto.response.PagedResponse; +import com.payflow.dto.response.UserResponse; import com.payflow.entity.User; import com.payflow.service.UserService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + @RestController -@RequestMapping("/users") +@RequestMapping("/api/v1/users") +@Validated public class UserController { private final UserService userService; @@ -25,27 +41,39 @@ public UserController(UserService userService) { } @PostMapping - public User registerUser(@RequestBody User user) { - return userService.registerUser(user); + public ResponseEntity registerUser(@Valid @RequestBody CreateUserRequest request) { + User createdUser = userService.registerUser(request); + UserResponse response = UserResponse.fromEntity(createdUser); + URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}") + .buildAndExpand(createdUser.getUserId()).toUri(); + return ResponseEntity.created(location).body(response); } @GetMapping - public List getAllUsers() { - return userService.getAllUsers(); + public ResponseEntity> getUsers(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int size, + @RequestParam(defaultValue = "userId") String sortBy) { + Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy).ascending()); + Page userPage = userService.getAllUsers(pageable).map(UserResponse::fromEntity); + return ResponseEntity.ok(PagedResponse.fromPage(userPage)); } @GetMapping("/{id}") - public Optional getUserById(@PathVariable Long id) { - return userService.getUserById(id); + public ResponseEntity getUserById(@PathVariable Long id) { + return userService.getUserById(id).map(UserResponse::fromEntity).map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); } @GetMapping("/upi/{upiId}") - public Optional getUserByUpiId(@PathVariable String upiId) { - return userService.findByUpiId(upiId); + public ResponseEntity getUserByUpiId(@PathVariable String upiId) { + return userService.findByUpiId(upiId).map(UserResponse::fromEntity).map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); } @GetMapping("/balance/{amount}") - public List getUsersWithBalanceAbove(@PathVariable BigDecimal amount) { - return userService.getUsersWithBalanceAbove(amount); + public ResponseEntity> getUsersWithBalanceAbove(@PathVariable BigDecimal amount) { + List entityList = userService.getUsersWithBalanceAbove(amount); + List users = entityList.stream().map(UserResponse::fromEntity).toList(); + return ResponseEntity.ok(users); } } diff --git a/src/main/java/com/payflow/dto/request/CreateUserRequest.java b/src/main/java/com/payflow/dto/request/CreateUserRequest.java new file mode 100644 index 0000000..186ab45 --- /dev/null +++ b/src/main/java/com/payflow/dto/request/CreateUserRequest.java @@ -0,0 +1,105 @@ +package com.payflow.dto.request; + +import java.math.BigDecimal; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class CreateUserRequest { + + @NotBlank(message = "Name cannot be blank") + @Size(max = 100, message = "Name must not exceed 100 characters") + private String name; + + @NotBlank(message = "UPI ID cannot be blank") + @Pattern(regexp = "^[a-zA-Z0-9.\\-_]{2,256}@[a-zA-Z]{2,64}$", message = "Invalid UPI ID format") + private String upiId; + + @NotBlank(message = "Phone number cannot be blank") + @Pattern(regexp = "^\\d{10}$", message = "Phone number must be exactly 10 digits") + private String phoneNumber; + + @NotNull(message = "Balance cannot be null") + @DecimalMin(value = "0.0", message = "Balance must be non-negative") + private BigDecimal balance; + + public CreateUserRequest() { + } + + public CreateUserRequest(String name, String upiId, String phoneNumber, BigDecimal balance) { + this.name = name; + this.upiId = upiId; + this.phoneNumber = phoneNumber; + this.balance = balance; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUpiId() { + return upiId; + } + + public void setUpiId(String upiId) { + this.upiId = upiId; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public BigDecimal getBalance() { + return balance; + } + + public void setBalance(BigDecimal balance) { + this.balance = balance; + } + + public static CreateUserRequestBuilder builder() { + return new CreateUserRequestBuilder(); + } + + public static class CreateUserRequestBuilder { + private String name; + private String upiId; + private String phoneNumber; + private BigDecimal balance; + + public CreateUserRequestBuilder name(String name) { + this.name = name; + return this; + } + + public CreateUserRequestBuilder upiId(String upiId) { + this.upiId = upiId; + return this; + } + + public CreateUserRequestBuilder phoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + return this; + } + + public CreateUserRequestBuilder balance(BigDecimal balance) { + this.balance = balance; + return this; + } + + public CreateUserRequest build() { + return new CreateUserRequest(name, upiId, phoneNumber, balance); + } + } +} diff --git a/src/main/java/com/payflow/dto/request/TransferMoneyRequest.java b/src/main/java/com/payflow/dto/request/TransferMoneyRequest.java new file mode 100644 index 0000000..1410b71 --- /dev/null +++ b/src/main/java/com/payflow/dto/request/TransferMoneyRequest.java @@ -0,0 +1,104 @@ +package com.payflow.dto.request; + +import java.math.BigDecimal; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class TransferMoneyRequest { + + @NotBlank(message = "Sender UPI ID cannot be blank") + @Pattern(regexp = "^[a-zA-Z0-9.\\-_]{2,256}@[a-zA-Z]{2,64}$", message = "Invalid sender UPI ID format") + private String senderUpiId; + + @NotBlank(message = "Receiver UPI ID cannot be blank") + @Pattern(regexp = "^[a-zA-Z0-9.\\-_]{2,256}@[a-zA-Z]{2,64}$", message = "Invalid receiver UPI ID format") + private String receiverUpiId; + + @NotNull(message = "Amount cannot be null") + @DecimalMin(value = "0.01", message = "Transfer amount must be at least 0.01") + private BigDecimal amount; + + @Size(max = 255, message = "Note must not exceed 255 characters") + private String note; + + public TransferMoneyRequest() { + } + + public TransferMoneyRequest(String senderUpiId, String receiverUpiId, BigDecimal amount, String note) { + this.senderUpiId = senderUpiId; + this.receiverUpiId = receiverUpiId; + this.amount = amount; + this.note = note; + } + + public String getSenderUpiId() { + return senderUpiId; + } + + public void setSenderUpiId(String senderUpiId) { + this.senderUpiId = senderUpiId; + } + + public String getReceiverUpiId() { + return receiverUpiId; + } + + public void setReceiverUpiId(String receiverUpiId) { + this.receiverUpiId = receiverUpiId; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public static TransferMoneyRequestBuilder builder() { + return new TransferMoneyRequestBuilder(); + } + + public static class TransferMoneyRequestBuilder { + private String senderUpiId; + private String receiverUpiId; + private BigDecimal amount; + private String note; + + public TransferMoneyRequestBuilder senderUpiId(String senderUpiId) { + this.senderUpiId = senderUpiId; + return this; + } + + public TransferMoneyRequestBuilder receiverUpiId(String receiverUpiId) { + this.receiverUpiId = receiverUpiId; + return this; + } + + public TransferMoneyRequestBuilder amount(BigDecimal amount) { + this.amount = amount; + return this; + } + + public TransferMoneyRequestBuilder note(String note) { + this.note = note; + return this; + } + + public TransferMoneyRequest build() { + return new TransferMoneyRequest(senderUpiId, receiverUpiId, amount, note); + } + } +} diff --git a/src/main/java/com/payflow/dto/response/PagedResponse.java b/src/main/java/com/payflow/dto/response/PagedResponse.java new file mode 100644 index 0000000..05b1882 --- /dev/null +++ b/src/main/java/com/payflow/dto/response/PagedResponse.java @@ -0,0 +1,13 @@ +package com.payflow.dto.response; + +import java.util.List; + +import org.springframework.data.domain.Page; + +public record PagedResponse(List content, int pageNumber, int pageSize, long totalElements, int totalPages, + boolean isLast) { + public static PagedResponse fromPage(Page page) { + return new PagedResponse<>(page.getContent(), page.getNumber(), page.getSize(), page.getTotalElements(), + page.getTotalPages(), page.isLast()); + } +} diff --git a/src/main/java/com/payflow/dto/response/TransactionResponse.java b/src/main/java/com/payflow/dto/response/TransactionResponse.java new file mode 100644 index 0000000..75624b5 --- /dev/null +++ b/src/main/java/com/payflow/dto/response/TransactionResponse.java @@ -0,0 +1,25 @@ +package com.payflow.dto.response; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import com.payflow.entity.Transaction; +import com.payflow.entity.TransactionStatus; +import com.payflow.entity.TransactionType; + +public record TransactionResponse(Long transactionId, UUID referenceId, String senderUpiId, String receiverUpiId, + BigDecimal amount, TransactionStatus status, TransactionType type, String note, Instant createdAt) { + public static TransactionResponse fromEntity(Transaction tx) { + Long id = tx.getTransactionId(); + UUID refId = tx.getReferenceId(); + String sender = tx.getSenderUpiId(); + String receiver = tx.getReceiverUpiId(); + BigDecimal amount = tx.getAmount(); + TransactionStatus status = tx.getStatus(); + TransactionType type = tx.getType(); + String note = tx.getNote(); + Instant created = tx.getCreatedAt(); + return new TransactionResponse(id, refId, sender, receiver, amount, status, type, note, created); + } +} diff --git a/src/main/java/com/payflow/dto/response/UserResponse.java b/src/main/java/com/payflow/dto/response/UserResponse.java new file mode 100644 index 0000000..27fae8f --- /dev/null +++ b/src/main/java/com/payflow/dto/response/UserResponse.java @@ -0,0 +1,14 @@ +package com.payflow.dto.response; + +import java.math.BigDecimal; +import java.time.Instant; + +import com.payflow.entity.User; + +public record UserResponse(Long userId, String name, String upiId, BigDecimal balance, String phoneNumber, + Instant createdAt, Instant updatedAt) { + public static UserResponse fromEntity(User user) { + return new UserResponse(user.getUserId(), user.getName(), user.getUpiId(), user.getBalance(), + user.getPhoneNumber(), user.getCreatedAt(), user.getUpdatedAt()); + } +} diff --git a/src/main/java/com/payflow/entity/Transaction.java b/src/main/java/com/payflow/entity/Transaction.java index 7e4c0af..aba53b8 100644 --- a/src/main/java/com/payflow/entity/Transaction.java +++ b/src/main/java/com/payflow/entity/Transaction.java @@ -18,19 +18,9 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; @Entity @Table(name = "transactions") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder public class Transaction { @Id @@ -72,10 +62,192 @@ public class Transaction { @Column(nullable = false, updatable = false) private Instant createdAt; + public Transaction() { + } + + public Transaction(TransactionBuilder builder) { + this.transactionId = builder.transactionId; + this.referenceId = builder.referenceId; + this.sender = builder.sender; + this.receiver = builder.receiver; + this.senderUpiId = builder.senderUpiId; + this.receiverUpiId = builder.receiverUpiId; + this.amount = builder.amount; + this.status = builder.status; + this.type = builder.type; + this.note = builder.note; + this.createdAt = builder.createdAt; + } + + public Long getTransactionId() { + return transactionId; + } + + public void setTransactionId(Long transactionId) { + this.transactionId = transactionId; + } + + public UUID getReferenceId() { + return referenceId; + } + + public void setReferenceId(UUID referenceId) { + this.referenceId = referenceId; + } + + public User getSender() { + return sender; + } + + public void setSender(User sender) { + this.sender = sender; + } + + public User getReceiver() { + return receiver; + } + + public void setReceiver(User receiver) { + this.receiver = receiver; + } + + public String getSenderUpiId() { + return senderUpiId; + } + + public void setSenderUpiId(String senderUpiId) { + this.senderUpiId = senderUpiId; + } + + public String getReceiverUpiId() { + return receiverUpiId; + } + + public void setReceiverUpiId(String receiverUpiId) { + this.receiverUpiId = receiverUpiId; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public TransactionStatus getStatus() { + return status; + } + + public void setStatus(TransactionStatus status) { + this.status = status; + } + + public TransactionType getType() { + return type; + } + + public void setType(TransactionType type) { + this.type = type; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + @PrePersist public void ensureReferenceId() { if (this.referenceId == null) { this.referenceId = UUID.randomUUID(); } } + + public static TransactionBuilder builder() { + return new TransactionBuilder(); + } + + public static class TransactionBuilder { + private Long transactionId; + private UUID referenceId; + private User sender; + private User receiver; + private String senderUpiId; + private String receiverUpiId; + private BigDecimal amount; + private TransactionStatus status; + private TransactionType type; + private String note; + private Instant createdAt; + + public TransactionBuilder transactionId(Long transactionId) { + this.transactionId = transactionId; + return this; + } + + public TransactionBuilder referenceId(UUID referenceId) { + this.referenceId = referenceId; + return this; + } + + public TransactionBuilder sender(User sender) { + this.sender = sender; + return this; + } + + public TransactionBuilder receiver(User receiver) { + this.receiver = receiver; + return this; + } + + public TransactionBuilder senderUpiId(String senderUpiId) { + this.senderUpiId = senderUpiId; + return this; + } + + public TransactionBuilder receiverUpiId(String receiverUpiId) { + this.receiverUpiId = receiverUpiId; + return this; + } + + public TransactionBuilder amount(BigDecimal amount) { + this.amount = amount; + return this; + } + + public TransactionBuilder status(TransactionStatus status) { + this.status = status; + return this; + } + + public TransactionBuilder type(TransactionType type) { + this.type = type; + return this; + } + + public TransactionBuilder note(String note) { + this.note = note; + return this; + } + + public TransactionBuilder createdAt(Instant createdAt) { + this.createdAt = createdAt; + return this; + } + + public Transaction build() { + return new Transaction(this); + } + } } diff --git a/src/main/java/com/payflow/entity/User.java b/src/main/java/com/payflow/entity/User.java index c1b9299..2d372ee 100644 --- a/src/main/java/com/payflow/entity/User.java +++ b/src/main/java/com/payflow/entity/User.java @@ -13,19 +13,9 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import jakarta.persistence.Version; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; @Entity @Table(name = "users") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder public class User { @Id @@ -55,6 +45,85 @@ public class User { @Column(nullable = false) private Instant updatedAt; + public User() { + } + + public User(Long userId, String name, String upiId, BigDecimal balance, String phoneNumber, Long version, + Instant createdAt, Instant updatedAt) { + this.userId = userId; + this.name = name; + this.upiId = upiId; + this.balance = balance; + this.phoneNumber = phoneNumber; + this.version = version; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUpiId() { + return upiId; + } + + public void setUpiId(String upiId) { + this.upiId = upiId; + } + + public BigDecimal getBalance() { + return balance; + } + + public void setBalance(BigDecimal balance) { + this.balance = balance; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public Long getVersion() { + return version; + } + + public void setVersion(Long version) { + this.version = version; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + public void debit(BigDecimal amount) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("Debit amount must be positive"); @@ -74,4 +143,63 @@ public void credit(BigDecimal amount) { } this.balance = this.balance.add(amount); } + + public static UserBuilder builder() { + return new UserBuilder(); + } + + public static class UserBuilder { + private Long userId; + private String name; + private String upiId; + private BigDecimal balance; + private String phoneNumber; + private Long version; + private Instant createdAt; + private Instant updatedAt; + + public UserBuilder userId(Long userId) { + this.userId = userId; + return this; + } + + public UserBuilder name(String name) { + this.name = name; + return this; + } + + public UserBuilder upiId(String upiId) { + this.upiId = upiId; + return this; + } + + public UserBuilder balance(BigDecimal balance) { + this.balance = balance; + return this; + } + + public UserBuilder phoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + return this; + } + + public UserBuilder version(Long version) { + this.version = version; + return this; + } + + public UserBuilder createdAt(Instant createdAt) { + this.createdAt = createdAt; + return this; + } + + public UserBuilder updatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public User build() { + return new User(userId, name, upiId, balance, phoneNumber, version, createdAt, updatedAt); + } + } } diff --git a/src/main/java/com/payflow/service/TransactionService.java b/src/main/java/com/payflow/service/TransactionService.java index 1ea3359..d5cc1c5 100644 --- a/src/main/java/com/payflow/service/TransactionService.java +++ b/src/main/java/com/payflow/service/TransactionService.java @@ -2,7 +2,10 @@ import org.springframework.stereotype.Service; +import com.payflow.dto.request.TransferMoneyRequest; import com.payflow.entity.Transaction; +import com.payflow.entity.TransactionStatus; +import com.payflow.entity.TransactionType; import com.payflow.repository.TransactionRepository; @Service @@ -14,7 +17,15 @@ public TransactionService(TransactionRepository transactionRepository) { this.transactionRepository = transactionRepository; } - public Transaction sendMoney(Transaction transaction) { + public Transaction sendMoney(TransferMoneyRequest request) { + Transaction.TransactionBuilder builder = Transaction.builder(); + builder.senderUpiId(request.getSenderUpiId()); + builder.receiverUpiId(request.getReceiverUpiId()); + builder.amount(request.getAmount()); + builder.status(TransactionStatus.COMPLETED); + builder.type(TransactionType.TRANSFER); + builder.note(request.getNote()); + Transaction transaction = builder.build(); return transactionRepository.save(transaction); } } diff --git a/src/main/java/com/payflow/service/UserService.java b/src/main/java/com/payflow/service/UserService.java index 33fac49..17b1cdd 100644 --- a/src/main/java/com/payflow/service/UserService.java +++ b/src/main/java/com/payflow/service/UserService.java @@ -4,8 +4,11 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import com.payflow.dto.request.CreateUserRequest; import com.payflow.entity.User; import com.payflow.repository.UserRepository; @@ -18,7 +21,9 @@ public UserService(UserRepository userRepository) { this.userRepository = userRepository; } - public User registerUser(User user) { + public User registerUser(CreateUserRequest request) { + User user = User.builder().name(request.getName()).upiId(request.getUpiId()) + .phoneNumber(request.getPhoneNumber()).balance(request.getBalance()).build(); return userRepository.save(user); } @@ -26,6 +31,10 @@ public List getAllUsers() { return userRepository.findAll(); } + public Page getAllUsers(Pageable pageable) { + return userRepository.findAll(pageable); + } + public Optional getUserById(Long id) { return userRepository.findById(id); } diff --git a/src/test/java/com/payflow/controller/TransactionControllerTest.java b/src/test/java/com/payflow/controller/TransactionControllerTest.java new file mode 100644 index 0000000..380ee98 --- /dev/null +++ b/src/test/java/com/payflow/controller/TransactionControllerTest.java @@ -0,0 +1,73 @@ +package com.payflow.controller; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import tools.jackson.databind.ObjectMapper; +import com.payflow.dto.request.TransferMoneyRequest; +import com.payflow.entity.Transaction; +import com.payflow.entity.TransactionStatus; +import com.payflow.entity.TransactionType; +import com.payflow.service.TransactionService; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TransactionController.class) +class TransactionControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private TransactionService transactionService; + + @Test + @DisplayName("POST /api/v1/transactions — Should execute transfer and return 201 Created") + void shouldSendMoney_whenRequestIsValid() throws Exception { + TransferMoneyRequest request = TransferMoneyRequest.builder().senderUpiId("alice@upi").receiverUpiId("bob@upi") + .amount(new BigDecimal("150.00")).note("Dinner split").build(); + + Transaction transaction = Transaction.builder().transactionId(101L).referenceId(UUID.randomUUID()) + .senderUpiId("alice@upi").receiverUpiId("bob@upi").amount(new BigDecimal("150.00")) + .status(TransactionStatus.COMPLETED).type(TransactionType.TRANSFER).note("Dinner split") + .createdAt(Instant.now()).build(); + + given(transactionService.sendMoney(any(TransferMoneyRequest.class))).willReturn(transaction); + + mockMvc.perform(post("/api/v1/transactions").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))).andExpect(status().isCreated()) + .andExpect(header().exists("Location")).andExpect(jsonPath("$.transactionId").value(101)) + .andExpect(jsonPath("$.senderUpiId").value("alice@upi")) + .andExpect(jsonPath("$.receiverUpiId").value("bob@upi")).andExpect(jsonPath("$.amount").value(150.00)) + .andExpect(jsonPath("$.status").value("COMPLETED")); + } + + @Test + @DisplayName("POST /api/v1/transactions — Should return 400 Bad Request when validation fails") + void shouldReturn400_whenTransferValidationFails() throws Exception { + TransferMoneyRequest invalidRequest = TransferMoneyRequest.builder().senderUpiId("") // Blank sender + .receiverUpiId("invalid-upi") // Invalid format + .amount(new BigDecimal("0.00")) // Amount < 0.01 + .build(); + + mockMvc.perform(post("/api/v1/transactions").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))).andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/payflow/controller/UserControllerTest.java b/src/test/java/com/payflow/controller/UserControllerTest.java new file mode 100644 index 0000000..bf44d72 --- /dev/null +++ b/src/test/java/com/payflow/controller/UserControllerTest.java @@ -0,0 +1,89 @@ +package com.payflow.controller; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import tools.jackson.databind.ObjectMapper; +import com.payflow.dto.request.CreateUserRequest; +import com.payflow.entity.User; +import com.payflow.service.UserService; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(UserController.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserService userService; + + @Test + @DisplayName("POST /api/v1/users — Should register user and return 201 Created") + void shouldRegisterUser_whenRequestIsValid() throws Exception { + CreateUserRequest request = CreateUserRequest.builder().name("Alice Smith").upiId("alice@upi") + .phoneNumber("9876543210").balance(new BigDecimal("1000.00")).build(); + + User createdUser = User.builder().userId(1L).name("Alice Smith").upiId("alice@upi").phoneNumber("9876543210") + .balance(new BigDecimal("1000.00")).createdAt(Instant.now()).updatedAt(Instant.now()).build(); + + given(userService.registerUser(any(CreateUserRequest.class))).willReturn(createdUser); + + mockMvc.perform(post("/api/v1/users").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))).andExpect(status().isCreated()) + .andExpect(header().exists("Location")).andExpect(jsonPath("$.userId").value(1)) + .andExpect(jsonPath("$.name").value("Alice Smith")).andExpect(jsonPath("$.upiId").value("alice@upi")) + .andExpect(jsonPath("$.balance").value(1000.00)); + } + + @Test + @DisplayName("POST /api/v1/users — Should return 400 Bad Request when validation fails") + void shouldReturn400_whenCreateUserValidationFails() throws Exception { + CreateUserRequest invalidRequest = CreateUserRequest.builder().name("") // Blank name + .upiId("invalid-upi-format").phoneNumber("123") // Not 10 digits + .balance(new BigDecimal("-50.00")) // Negative balance + .build(); + + mockMvc.perform(post("/api/v1/users").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))).andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("GET /api/v1/users/{id} — Should return user response when found") + void shouldReturnUser_whenFoundById() throws Exception { + User user = User.builder().userId(1L).name("Bob").upiId("bob@upi").phoneNumber("9876543211") + .balance(new BigDecimal("500.00")).createdAt(Instant.now()).updatedAt(Instant.now()).build(); + + given(userService.getUserById(1L)).willReturn(Optional.of(user)); + + mockMvc.perform(get("/api/v1/users/1")).andExpect(status().isOk()).andExpect(jsonPath("$.userId").value(1)) + .andExpect(jsonPath("$.upiId").value("bob@upi")); + } + + @Test + @DisplayName("GET /api/v1/users/{id} — Should return 404 Not Found when user does not exist") + void shouldReturn404_whenUserNotFound() throws Exception { + given(userService.getUserById(99L)).willReturn(Optional.empty()); + + mockMvc.perform(get("/api/v1/users/99")).andExpect(status().isNotFound()); + } +}