diff --git a/.gitignore b/.gitignore index 67045665db..ee69a9ac18 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,9 @@ dist # TernJS port file .tern-port + +# IntelliJ +.idea/ + +# Quarkus +.quarkus/ diff --git a/SOLUTION.md b/SOLUTION.md new file mode 100644 index 0000000000..061c3b5cc5 --- /dev/null +++ b/SOLUTION.md @@ -0,0 +1,260 @@ +# Yape Code Challenge - Enterprise Solution :rocket: + +[![Postman Collection](https://img.shields.io/badge/Postman-Collection-orange?logo=postman)](https://www.postman.com/me-juda-carrillo/yape-code-challenge/) + +## Table of Contents + +- [Overview](#overview) +- [Solution Architecture](#solution-architecture) +- [Problem Resolution](#problem-resolution) +- [Why Quarkus?](#why-quarkus) +- [Enterprise Features](#enterprise-features) +- [Technical Stack](#technical-stack) +- [Getting Started](#getting-started) +- [API Usage](#api-usage) +- [Testing](#testing) + +--- + +## Overview + +This solution implements a **production-ready microservices architecture** for the Yape Code Challenge, demonstrating how a fintech company would build a scalable transaction processing system. + +**Key Highlights:** +- Event-Driven Architecture with Apache Kafka & Avro +- GraphQL API (instead of REST) +- Clean Architecture + CQRS Pattern +- Multi-layer Caching with Redis +- 94% Test Coverage + +--- + +## Solution Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CLIENT (GraphQL) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MS-TRANSACTION (:18080) │ +│ GraphQL → Use Cases → Domain Services → [PostgreSQL, Redis, Kafka] │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────────┴───────────────────────┐ + │ transaction.created transaction.status │ + ▼ ▲ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MS-ANTI-FRAUD (:18081) │ +│ Kafka Consumer → Validation Service → Kafka Producer │ +│ (Reject if value > 1000) │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PostgreSQL:5432 │ Redis:6379 │ Kafka:9092 │ Schema Registry:8081 │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Problem Resolution + +| Requirement | Implementation | +|-------------|---------------| +| Create transaction with pending status | ✅ GraphQL mutation saves with `PENDING` status | +| Anti-fraud validation via messaging | ✅ Kafka event to `transaction.created` topic | +| Reject transactions > 1000 | ✅ `AntiFraudValidationService` validates threshold | +| Update status via messaging | ✅ Response via `transaction.status` topic | +| High-volume support | ✅ Redis caching + Kafka async processing | + +--- + +## Why Quarkus? + +| Metric | Spring Boot | Quarkus JVM | Quarkus Native | +|--------|-------------|-------------|----------------| +| Startup Time | ~3-5s | ~0.8s | ~15ms | +| Memory (RSS) | ~300MB | ~150MB | ~35MB | +| Docker Image | ~300MB | ~200MB | ~70MB | + +**Enterprise Benefits:** +- **Cloud-Native**: Built for Kubernetes & containers +- **Native Compilation**: GraalVM support for serverless +- **Reactive**: Non-blocking Kafka processing +- **Developer Experience**: Live reload with `quarkus:dev` +- **Standards**: MicroProfile, Jakarta EE, Vert.x + +--- + +## Enterprise Features + +### Clean Architecture +``` +src/main/java/com/yape/services/ +├── expose/graphql/ # GraphQL Resolvers +├── transaction/ +│ ├── application/ # Use Cases, Commands, Queries +│ ├── domain/ # Models, Repository Interfaces +│ └── infrastructure/ # PostgreSQL, Redis, Kafka +└── shared/exception/ # Error Handling +``` + +### Event-Driven with Avro Schemas +- Schema evolution with Confluent Schema Registry +- Type-safe serialization +- Request traceability via `requestId` + +### Caching Strategy +| Status | TTL | Rationale | +|--------|-----|-----------| +| PENDING | 5 min | Status will change | +| APPROVED/REJECTED | 1 hour | Final state | + +--- + +## Technical Stack + +| Component | Technology | Version | +|-----------|------------|---------| +| Framework | Quarkus | 3.31.1 | +| Language | Java | 21 | +| API | SmallRye GraphQL | - | +| Database | PostgreSQL | 14 | +| Cache | Redis | 8 | +| Messaging | Kafka + Avro | 7.9.0 | +| ORM | Hibernate Panache | - | +| Migrations | Flyway | - | + +--- + +## Getting Started + +### Prerequisites +- Docker & Docker Compose + +### 1. Start All Services +```bash +docker-compose up -d +``` + +This command starts all services: +- **PostgreSQL** (:5432) - Transaction database +- **Redis** (:6379) - Caching layer +- **Zookeeper** (:2181) - Kafka coordination +- **Kafka** (:9092) - Event streaming +- **Schema Registry** (:8081) - Avro schema management +- **ms-transaction** (:18080) - Transaction microservice +- **ms-anti-fraud** (:18081) - Anti-fraud microservice + +### 2. Verify Health +```bash +# Wait ~2 minutes for services to be ready, then: +curl http://localhost:18080/ms-transaction/health +curl http://localhost:18081/ms-anti-fraud/health +``` + +### 3. Stop Services +```bash +docker-compose down +``` + +### Development Mode (Optional) +For local development with hot-reload: +```bash +# Start only infrastructure +docker-compose up -d postgres redis zookeeper kafka schema-registry + +# Run microservices locally +cd ms-transaction && ./mvnw quarkus:dev +cd ms-anti-fraud && ./mvnw quarkus:dev +``` + +--- + +## API Usage + +### Step 1: Fetch Transfer Types + +```graphql +query { + transferTypes { + transferTypeId + name + } +} +``` + +![Fetch Transfer Types](assets/01-success-fetch-transfer-types.png) + +--- + +### Step 2: Create Transaction + +```graphql +mutation { + createTransaction(input: { + accountExternalIdDebit: "550e8400-e29b-41d4-a716-446655440000" + accountExternalIdCredit: "550e8400-e29b-41d4-a716-446655440001" + transferTypeId: 1 + value: "500.00" + }) { + transactionExternalId + transactionStatus { name } + value + createdAt + } +} +``` + +![Create Transaction](assets/02-success-create-transaction.png) + +--- + +### Step 3: Fetch Transaction + +```graphql +query { + transaction(transactionExternalId: "YOUR_TRANSACTION_ID") { + transactionExternalId + transactionType { name } + transactionStatus { name } + value + createdAt + } +} +``` + +![Fetch Transaction](assets/03-success-fetch-transaction.png) + +--- + +## Testing + +### Run Tests +```bash +cd ms-transaction && ./mvnw test +cd ms-anti-fraud && ./mvnw test +``` + +### Coverage Report +```bash +./mvnw verify +# Report: target/site/jacoco/index.html +``` + +**ms-transaction: 94% coverage** + +| Package | Coverage | +|---------|----------| +| application.usecase | 94% | +| application.query | 100% | +| infrastructure.cache | 100% | +| infrastructure.messaging | 100% | + +--- + +## Author + +**Juda Carrillo** - Tech Lead | Senior Backend Developer +📧 [jbcp2006@gmail.com](mailto:jbcp2006@gmail.com) diff --git a/assets/01-success-fetch-transfer-types.png b/assets/01-success-fetch-transfer-types.png new file mode 100644 index 0000000000..b6d5bfbb33 Binary files /dev/null and b/assets/01-success-fetch-transfer-types.png differ diff --git a/assets/02-success-create-transaction.png b/assets/02-success-create-transaction.png new file mode 100644 index 0000000000..bb8ae10eec Binary files /dev/null and b/assets/02-success-create-transaction.png differ diff --git a/assets/03-success-fetch-transaction.png b/assets/03-success-fetch-transaction.png new file mode 100644 index 0000000000..f9bd04be54 Binary files /dev/null and b/assets/03-success-fetch-transaction.png differ diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..254e3bcf72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,169 @@ -version: "3.7" services: + redis: + image: redis:8 + container_name: yape-redis + ports: + - "6379:6379" + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + volumes: + - redis_data:/data + postgres: image: postgres:14 + container_name: yape-postgres ports: - "5432:5432" environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: yape_transactions + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + zookeeper: - image: confluentinc/cp-zookeeper:5.5.3 + image: confluentinc/cp-zookeeper:7.9.0 + container_name: yape-zookeeper environment: ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + healthcheck: + test: [ "CMD", "nc", "-z", "localhost", "2181" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + kafka: - image: confluentinc/cp-enterprise-kafka:5.5.3 - depends_on: [zookeeper] + image: confluentinc/cp-enterprise-kafka:7.9.0 + container_name: yape-kafka + depends_on: + zookeeper: + condition: service_healthy + ports: + - "9092:9092" environment: - KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT - KAFKA_BROKER_ID: 1 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_JMX_PORT: 9991 + healthcheck: + test: [ "CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092" ] + interval: 10s + timeout: 10s + retries: 5 + networks: + - yape-network + + schema-registry: + image: confluentinc/cp-schema-registry:7.9.0 + container_name: yape-schema-registry + depends_on: + kafka: + condition: service_healthy ports: - - 9092:9092 + - "8081:8081" + environment: + SCHEMA_REGISTRY_HOST_NAME: schema-registry + SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: kafka:29092 + SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081 + SCHEMA_REGISTRY_KAFKASTORE_TOPIC: _schemas + SCHEMA_REGISTRY_KAFKASTORE_REPLICATION_FACTOR: 1 + SCHEMA_REGISTRY_DEBUG: "false" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8081/subjects" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + + ms-transaction: + build: + context: ./ms-transaction + dockerfile: devops/docker/Dockerfile.multistage + container_name: yape-ms-transaction + ports: + - "18080:18080" + environment: + QUARKUS_PROFILE: local + # PostgreSQL + QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://postgres:5432/yape_transactions + QUARKUS_DATASOURCE_USERNAME: postgres + QUARKUS_DATASOURCE_PASSWORD: postgres + # Flyway + QUARKUS_FLYWAY_MIGRATE_AT_START: "true" + # Redis + QUARKUS_REDISSON_SINGLE_SERVER_CONFIG_ADDRESS: redis://redis:6379 + # Kafka + KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + depends_on: + postgres: + condition: service_healthy + kafka: + condition: service_healthy + schema-registry: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:18080/ms-transaction/health" ] + interval: 15s + timeout: 10s + retries: 10 + start_period: 120s + networks: + - yape-network + + ms-anti-fraud: + build: + context: ./ms-anti-fraud + dockerfile: devops/docker/Dockerfile.multistage + container_name: yape-ms-anti-fraud + ports: + - "18081:18081" + environment: + QUARKUS_PROFILE: local + # Kafka + KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + depends_on: + kafka: + condition: service_healthy + schema-registry: + condition: service_healthy + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:18081/ms-anti-fraud/health" ] + interval: 15s + timeout: 10s + retries: 10 + start_period: 120s + networks: + - yape-network + +volumes: + postgres_data: + redis_data: + +networks: + yape-network: + driver: bridge diff --git a/ms-anti-fraud/.dockerignore b/ms-anti-fraud/.dockerignore new file mode 100644 index 0000000000..4cdd908d17 --- /dev/null +++ b/ms-anti-fraud/.dockerignore @@ -0,0 +1,22 @@ +# Ignore IDE files +.idea/ +*.iml +.vscode/ +.eclipse/ + +# Ignore build output (we build inside container) +target/ + +# Ignore git +.git/ +.gitignore + +# Ignore documentation +*.md +docs/ + +# Ignore test resources that aren't needed +src/test/resources/ + +# Ignore local config +.claude/ \ No newline at end of file diff --git a/ms-anti-fraud/.gitignore b/ms-anti-fraud/.gitignore new file mode 100644 index 0000000000..1cf10f5273 --- /dev/null +++ b/ms-anti-fraud/.gitignore @@ -0,0 +1,50 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ +# TLS Certificates +.certs/ + +# Maven Wrapper +mvnw +mvnw.cmd +.mvn diff --git a/ms-anti-fraud/README.md b/ms-anti-fraud/README.md new file mode 100644 index 0000000000..5d06ff7672 --- /dev/null +++ b/ms-anti-fraud/README.md @@ -0,0 +1,62 @@ +# ms-anti-fraud + +This project uses Quarkus, the Supersonic Subatomic Java Framework. + +If you want to learn more about Quarkus, please visit its website: . + +## Running the application in dev mode + +You can run your application in dev mode that enables live coding using: + +```shell script +./mvnw quarkus:dev +``` + +> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at . + +## Packaging and running the application + +The application can be packaged using: + +```shell script +./mvnw package +``` + +It produces the `quarkus-run.jar` file in the `target/quarkus-app/` directory. +Be aware that it’s not an _über-jar_ as the dependencies are copied into the `target/quarkus-app/lib/` directory. + +The application is now runnable using `java -jar target/quarkus-app/quarkus-run.jar`. + +If you want to build an _über-jar_, execute the following command: + +```shell script +./mvnw package -Dquarkus.package.jar.type=uber-jar +``` + +The application, packaged as an _über-jar_, is now runnable using `java -jar target/*-runner.jar`. + +## Creating a native executable + +You can create a native executable using: + +```shell script +./mvnw package -Dnative +``` + +Or, if you don't have GraalVM installed, you can run the native executable build in a container using: + +```shell script +./mvnw package -Dnative -Dquarkus.native.container-build=true +``` + +You can then execute your native executable with: `./target/ms-anti-fraud-1.0.0-SNAPSHOT-runner` + +If you want to learn more about building native executables, please consult . + +## Provided Code + +### REST + +Easily start your REST Web Services + +[Related guide section...](https://quarkus.io/guides/getting-started-reactive#reactive-jax-rs-resources) diff --git a/ms-anti-fraud/checkstyle-suppressions.xml b/ms-anti-fraud/checkstyle-suppressions.xml new file mode 100644 index 0000000000..2161f901d9 --- /dev/null +++ b/ms-anti-fraud/checkstyle-suppressions.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/ms-anti-fraud/checkstyle.xml b/ms-anti-fraud/checkstyle.xml new file mode 100644 index 0000000000..745a2ada9e --- /dev/null +++ b/ms-anti-fraud/checkstyle.xml @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ms-anti-fraud/devops/docker/Dockerfile.jvm b/ms-anti-fraud/devops/docker/Dockerfile.jvm new file mode 100644 index 0000000000..97416e8460 --- /dev/null +++ b/ms-anti-fraud/devops/docker/Dockerfile.jvm @@ -0,0 +1,32 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./mvnw package +# +# Then, build the image with: +# +# docker build -f devops/docker/Dockerfile.jvm -t quarkus/ms-anti-fraud-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 18081:18081 quarkus/ms-anti-fraud-jvm +# +### +FROM registry.access.redhat.com/ubi9/openjdk-21:1.23 + +ENV LANGUAGE='en_US:en' + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 target/quarkus-app/*.jar /deployments/ +COPY --chown=185 target/quarkus-app/app/ /deployments/app/ +COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 18081 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] diff --git a/ms-anti-fraud/devops/docker/Dockerfile.multistage b/ms-anti-fraud/devops/docker/Dockerfile.multistage new file mode 100644 index 0000000000..50d2db6f61 --- /dev/null +++ b/ms-anti-fraud/devops/docker/Dockerfile.multistage @@ -0,0 +1,40 @@ +#### +# Multi-stage Dockerfile for ms-anti-fraud +# Compiles the application and creates a minimal runtime image +#### + +# Stage 1: Build +FROM maven:3.9.9-eclipse-temurin-21 AS builder + +WORKDIR /build + +# Copy pom.xml and checkstyle config first for better caching +COPY pom.xml checkstyle.xml checkstyle-suppressions.xml ./ + +# Download dependencies (cached layer) +RUN mvn dependency:go-offline -B + +# Copy source code +COPY src ./src + +# Build the application +RUN mvn package -DskipTests -B + +# Stage 2: Runtime +FROM registry.access.redhat.com/ubi9/openjdk-21:1.23 + +ENV LANGUAGE='en_US:en' + +# Copy the built application from builder stage +COPY --from=builder --chown=185 /build/target/quarkus-app/lib/ /deployments/lib/ +COPY --from=builder --chown=185 /build/target/quarkus-app/*.jar /deployments/ +COPY --from=builder --chown=185 /build/target/quarkus-app/app/ /deployments/app/ +COPY --from=builder --chown=185 /build/target/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 18081 +USER 185 + +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] \ No newline at end of file diff --git a/ms-anti-fraud/pom.xml b/ms-anti-fraud/pom.xml new file mode 100644 index 0000000000..45e86b3832 --- /dev/null +++ b/ms-anti-fraud/pom.xml @@ -0,0 +1,273 @@ + + + 4.0.0 + + + 3.14.1 + 21 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.31.1 + true + 3.5.4 + 7.9.0 + 0.8.12 + 3.6.0 + 12.1.2 + 4.8.6.6 + + + com.yape.services + ms-anti-fraud + 1.0.0-SNAPSHOT + quarkus + + MS Anti Fraud + Microservice for processing and validating transactions + 2026 + + + Yape + https://www.yape.com.pe + + + + + jcarripa + Juda Carrillo + jbcp2006@gmail.com + America/Lima + + Tech Lead + Senior Backend Developer + + + + + + + confluent + https://packages.confluent.io/maven/ + + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-config-yaml + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-hibernate-validator + + + + + io.quarkus + quarkus-messaging-kafka + + + + io.quarkus + quarkus-avro + + + io.quarkus + quarkus-confluent-registry-avro + + + io.confluent + kafka-avro-serializer + ${confluent.version} + + + + + io.quarkus + quarkus-junit + test + + + io.quarkus + quarkus-junit-mockito + test + + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + @{argLine} + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + @{argLine} + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle-plugin.version} + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + + checkstyle.xml + checkstyle-suppressions.xml + true + true + true + + + + validate + validate + + check + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-plugin.version} + + spotbugs-exclude.xml + Max + Low + + + + spotbugs-check + verify + + check + + + + + + + + + + native + + + native + + + + false + false + true + + + + diff --git a/ms-anti-fraud/spotbugs-exclude.xml b/ms-anti-fraud/spotbugs-exclude.xml new file mode 100644 index 0000000000..150e3d46e1 --- /dev/null +++ b/ms-anti-fraud/spotbugs-exclude.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc new file mode 100644 index 0000000000..dd825a7498 --- /dev/null +++ b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc @@ -0,0 +1,111 @@ +{ + "type": "record", + "name": "TransactionCreatedEvent", + "namespace": "com.yape.services.transaction.events", + "doc": "Emitted event when a new transaction is created. Published to the topic 'transaction.created' for the Anti-Fraud service to validate the transaction.", + "fields": [ + { + "name": "metadata", + "type": { + "type": "record", + "name": "EventMetadata", + "namespace": "com.yape.services.common.events", + "doc": "Standard metadata for all system events", + "fields": [ + { + "name": "eventId", + "type": "string", + "doc": "Unique identifier of the event (UUID v4)" + }, + { + "name": "eventType", + "type": "string", + "doc": "Type of the event (e.g., TRANSACTION_CREATED, TRANSACTION_STATUS_UPDATED)" + }, + { + "name": "eventTimestamp", + "type": "string", + "doc": "Timestamp of event creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + }, + { + "name": "source", + "type": "string", + "doc": "Service originating the event (e.g., ms-transaction, ms-anti-fraud)" + }, + { + "name": "version", + "type": "string", + "default": "1.0.0", + "doc": "Version of the event schema" + }, + { + "name": "requestId", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Original Request-Id of the HTTP request that triggered the event" + } + ] + }, + "doc": "Event metadata" + }, + { + "name": "payload", + "type": { + "type": "record", + "name": "TransactionCreatedPayload", + "doc": "Payload of the created transaction", + "fields": [ + { + "name": "transactionExternalId", + "type": "string", + "doc": "Unique external identifier of the transaction (UUID v4)" + }, + { + "name": "accountExternalIdDebit", + "type": "string", + "doc": "Debit account external identifier" + }, + { + "name": "accountExternalIdCredit", + "type": "string", + "doc": "Credit account external identifier" + }, + { + "name": "transferTypeId", + "type": "int", + "doc": "Transfer type identifier." + }, + { + "name": "value", + "type": "string", + "doc": "Transaction monetary value as string to preserve precision" + }, + { + "name": "status", + "type": { + "type": "enum", + "name": "TransactionStatus", + "namespace": "com.yape.services.transaction.events.enums", + "doc": "Possible statuses of a financial transaction", + "symbols": [ + "PENDING", + "APPROVED", + "REJECTED" + ] + }, + "doc": "Current status of the transaction (always PENDING upon creation)" + }, + { + "name": "createdAt", + "type": "string", + "doc": "Timestamp of transaction creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + } + ] + }, + "doc": "Payload of the created transaction" + } + ] +} \ No newline at end of file diff --git a/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc new file mode 100644 index 0000000000..049e9b81b1 --- /dev/null +++ b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -0,0 +1,126 @@ +{ + "type": "record", + "name": "TransactionStatusUpdatedEvent", + "namespace": "com.yape.services.transaction.events", + "doc": "Emitted event when Anti-Fraud service updates the status of a transaction. Published to the topic 'transaction.status' for ms-transaction to update the status in the database.", + "fields": [ + { + "name": "metadata", + "type": { + "type": "record", + "name": "EventMetadata", + "namespace": "com.yape.services.common.events", + "doc": "Standard metadata for all system events", + "fields": [ + { + "name": "eventId", + "type": "string", + "doc": "Unique identifier of the event (UUID v4)" + }, + { + "name": "eventType", + "type": "string", + "doc": "Type of the event (e.g., TRANSACTION_CREATED, TRANSACTION_STATUS_UPDATED)" + }, + { + "name": "eventTimestamp", + "type": "string", + "doc": "Timestamp of event creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + }, + { + "name": "source", + "type": "string", + "doc": "Service originating the event (e.g., ms-transaction, ms-anti-fraud)" + }, + { + "name": "version", + "type": "string", + "default": "1.0.0", + "doc": "Version of the event schema" + }, + { + "name": "requestId", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Original Request-Id of the HTTP request that triggered the event" + } + ] + }, + "doc": "Event metadata" + }, + { + "name": "payload", + "type": { + "type": "record", + "name": "TransactionStatusUpdatedPayload", + "doc": "Payload with the update data", + "fields": [ + { + "name": "transactionExternalId", + "type": "string", + "doc": "Unique external identifier of the transaction (UUID v4)" + }, + { + "name": "previousStatus", + "type": { + "type": "enum", + "name": "TransactionStatus", + "namespace": "com.yape.services.transaction.events.enums", + "doc": "Possible statuses of a financial transaction", + "symbols": [ + "PENDING", + "APPROVED", + "REJECTED" + ] + }, + "doc": "Previous status of the transaction" + }, + { + "name": "newStatus", + "type": "com.yape.services.transaction.events.enums.TransactionStatus", + "doc": "New status of the transaction" + }, + { + "name": "value", + "type": "string", + "doc": "Monetary value of the transaction as a string to preserve precision" + }, + { + "name": "validationResult", + "type": { + "type": "record", + "name": "ValidationResult", + "doc": "Details of the anti-fraud validation result", + "fields": [ + { + "name": "isValid", + "type": "boolean", + "doc": "Indicates if the transaction passed the anti-fraud validation" + }, + { + "name": "ruleCode", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Code of the applied rule (e.g., MAX_AMOUNT_EXCEEDED)" + } + ] + }, + "doc": "Details of the anti-fraud validation result" + }, + { + "name": "processedAt", + "type": "string", + "doc": "Timestamp when the validation was processed in ISO 8601 format" + } + ] + }, + "doc": "Payload with the update data" + } + ] +} \ No newline at end of file diff --git a/ms-anti-fraud/src/main/java/com/yape/services/common/util/Constants.java b/ms-anti-fraud/src/main/java/com/yape/services/common/util/Constants.java new file mode 100644 index 0000000000..a6f759170e --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/common/util/Constants.java @@ -0,0 +1,29 @@ +package com.yape.services.common.util; + +/** + * Constants used across the anti-fraud service. + */ +public final class Constants { + + private Constants() { + // Utility class, prevent instantiation + } + + // Event sources + public static final String EVENT_SOURCE = "ms-anti-fraud"; + + // Event types + public static final String EVENT_TYPE_TRANSACTION_STATUS_UPDATED = "TRANSACTION_STATUS_UPDATED"; + + // Transaction statuses + public static final String TRANSACTION_STATUS_PENDING = "PENDING"; + public static final String TRANSACTION_STATUS_APPROVED = "APPROVED"; + public static final String TRANSACTION_STATUS_REJECTED = "REJECTED"; + + // Validation rules + public static final String RULE_MAX_AMOUNT_EXCEEDED = "MAX_AMOUNT_EXCEEDED"; + + // Default schema version + public static final String SCHEMA_VERSION = "1.0.0"; + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCase.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCase.java new file mode 100644 index 0000000000..8aa9869aa1 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCase.java @@ -0,0 +1,118 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.common.util.Constants; +import com.yape.services.transaction.domain.service.AntiFraudValidationService; +import com.yape.services.transaction.domain.service.TransactionStatusEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.events.TransactionCreatedPayload; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import com.yape.services.transaction.events.ValidationResult; +import com.yape.services.transaction.events.enums.TransactionStatus; +import jakarta.enterprise.context.ApplicationScoped; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Use case for validating a transaction against anti-fraud rules. + * Receives a TransactionCreatedEvent, validates it, and publishes + * a TransactionStatusUpdatedEvent with the result. + */ +@ApplicationScoped +public class ValidateTransactionUseCase { + + private static final Logger LOGGER = Logger.getLogger(ValidateTransactionUseCase.class); + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ") + .withZone(ZoneOffset.UTC); + + private final AntiFraudValidationService antiFraudValidationService; + private final TransactionStatusEventPublisher eventPublisher; + + /** + * Constructor for ValidateTransactionUseCase. + * + * @param antiFraudValidationService the service for validating transactions + * @param eventPublisher the publisher for transaction status events + */ + public ValidateTransactionUseCase(AntiFraudValidationService antiFraudValidationService, + TransactionStatusEventPublisher eventPublisher) { + this.antiFraudValidationService = antiFraudValidationService; + this.eventPublisher = eventPublisher; + } + + /** + * Executes the anti-fraud validation for a transaction. + * + * @param event the transaction created event to validate + */ + public void execute(TransactionCreatedEvent event) { + TransactionCreatedPayload payload = event.getPayload(); + String transactionExternalId = payload.getTransactionExternalId(); + + LOGGER.infof("Processing transaction for anti-fraud validation: %s", transactionExternalId); + + BigDecimal transactionValue = new BigDecimal(payload.getValue()); + com.yape.services.transaction.domain.model.ValidationResult result = + antiFraudValidationService.validate(transactionValue); + + TransactionStatus newStatus = result.isValid() + ? TransactionStatus.APPROVED + : TransactionStatus.REJECTED; + + TransactionStatusUpdatedEvent statusUpdatedEvent = buildStatusUpdatedEvent( + event, + payload, + result, + newStatus + ); + + eventPublisher.publishStatusUpdated(statusUpdatedEvent); + + LOGGER.infof("Transaction %s validation completed with status: %s", + transactionExternalId, newStatus); + } + + private TransactionStatusUpdatedEvent buildStatusUpdatedEvent( + TransactionCreatedEvent originalEvent, + TransactionCreatedPayload payload, + com.yape.services.transaction.domain.model.ValidationResult result, + TransactionStatus newStatus) { + + String requestId = originalEvent.getMetadata().getRequestId(); + + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType(Constants.EVENT_TYPE_TRANSACTION_STATUS_UPDATED) + .setEventTimestamp(ISO_FORMATTER.format(Instant.now())) + .setSource(Constants.EVENT_SOURCE) + .setVersion(Constants.SCHEMA_VERSION) + .setRequestId(requestId) + .build(); + + ValidationResult validationResult = ValidationResult.newBuilder() + .setIsValid(result.isValid()) + .setRuleCode(result.getRuleCode()) + .build(); + + TransactionStatusUpdatedPayload statusPayload = TransactionStatusUpdatedPayload.newBuilder() + .setTransactionExternalId(payload.getTransactionExternalId()) + .setPreviousStatus(TransactionStatus.PENDING) + .setNewStatus(newStatus) + .setValue(payload.getValue()) + .setValidationResult(validationResult) + .setProcessedAt(ISO_FORMATTER.format(Instant.now())) + .build(); + + return TransactionStatusUpdatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(statusPayload) + .build(); + } + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/model/ValidationResult.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/model/ValidationResult.java new file mode 100644 index 0000000000..9cccef4e6e --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/model/ValidationResult.java @@ -0,0 +1,43 @@ +package com.yape.services.transaction.domain.model; + +/** + * Result of the anti-fraud validation process. + */ +public class ValidationResult { + + private final boolean valid; + private final String ruleCode; + + private ValidationResult(boolean valid, String ruleCode) { + this.valid = valid; + this.ruleCode = ruleCode; + } + + /** + * Creates a validation result indicating approval. + * + * @return ValidationResult indicating the transaction is approved + */ + public static ValidationResult approved() { + return new ValidationResult(true, null); + } + + /** + * Creates a validation result indicating rejection. + * + * @param ruleCode the code of the rule that caused rejection + * @return ValidationResult indicating the transaction is rejected + */ + public static ValidationResult rejected(String ruleCode) { + return new ValidationResult(false, ruleCode); + } + + public boolean isValid() { + return valid; + } + + public String getRuleCode() { + return ruleCode; + } + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/AntiFraudValidationService.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/AntiFraudValidationService.java new file mode 100644 index 0000000000..07b2bd7fb6 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/AntiFraudValidationService.java @@ -0,0 +1,44 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.common.util.Constants; +import com.yape.services.transaction.domain.model.ValidationResult; +import jakarta.enterprise.context.ApplicationScoped; +import java.math.BigDecimal; +import org.jboss.logging.Logger; + +/** + * Domain service responsible for anti-fraud transaction validation. + * Implements business rules to determine if a transaction should be approved or rejected. + */ +@ApplicationScoped +public class AntiFraudValidationService { + + private static final Logger LOGGER = Logger.getLogger(AntiFraudValidationService.class); + + /** + * Maximum allowed transaction amount. + * Transactions exceeding this value will be rejected. + */ + private static final BigDecimal MAX_TRANSACTION_AMOUNT = new BigDecimal("1000"); + + /** + * Validates a transaction based on anti-fraud rules. + * + * @param transactionValue the monetary value of the transaction + * @return ValidationResult indicating whether the transaction is approved or rejected + */ + public ValidationResult validate(BigDecimal transactionValue) { + LOGGER.debugf("Validating transaction with value: %s", transactionValue); + + // Rule: Reject transactions with value greater than 1000 + if (transactionValue.compareTo(MAX_TRANSACTION_AMOUNT) > 0) { + LOGGER.infof("Transaction rejected: value %s exceeds maximum allowed %s", + transactionValue, MAX_TRANSACTION_AMOUNT); + return ValidationResult.rejected(Constants.RULE_MAX_AMOUNT_EXCEEDED); + } + + LOGGER.debugf("Transaction approved with value: %s", transactionValue); + return ValidationResult.approved(); + } + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/TransactionStatusEventPublisher.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/TransactionStatusEventPublisher.java new file mode 100644 index 0000000000..e8ec97f2b8 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/TransactionStatusEventPublisher.java @@ -0,0 +1,17 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; + +/** + * Interface for publishing transaction status update events. + */ +public interface TransactionStatusEventPublisher { + + /** + * Publishes a transaction status updated event. + * + * @param event the event to publish + */ + void publishStatusUpdated(TransactionStatusUpdatedEvent event); + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumer.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumer.java new file mode 100644 index 0000000000..d16f9ffd84 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumer.java @@ -0,0 +1,52 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import com.yape.services.transaction.application.usecase.ValidateTransactionUseCase; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.jboss.logging.Logger; + +/** + * Kafka consumer for transaction created events. + * Listens to the 'transaction.created' topic and delegates to the validation use case. + */ +@ApplicationScoped +public class KafkaTransactionCreatedConsumer { + + private static final Logger LOGGER = Logger.getLogger(KafkaTransactionCreatedConsumer.class); + + private final ValidateTransactionUseCase validateTransactionUseCase; + + /** + * Constructor for KafkaTransactionCreatedConsumer. + * + * @param validateTransactionUseCase the use case for validating transactions + */ + @Inject + public KafkaTransactionCreatedConsumer(ValidateTransactionUseCase validateTransactionUseCase) { + this.validateTransactionUseCase = validateTransactionUseCase; + } + + /** + * Consumes transaction created events from Kafka. + * + * @param kafkaRecord the Kafka record containing the transaction created event + */ + @Incoming("transaction-created-consumer") + public void consume(Record kafkaRecord) { + String key = kafkaRecord.key(); + TransactionCreatedEvent event = kafkaRecord.value(); + + LOGGER.infof("Received TransactionCreatedEvent with key: %s", key); + + try { + validateTransactionUseCase.execute(event); + } catch (Exception e) { + LOGGER.errorf(e, "Error processing TransactionCreatedEvent with key: %s", key); + throw e; + } + } + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisher.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisher.java new file mode 100644 index 0000000000..b05297c520 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisher.java @@ -0,0 +1,54 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import com.yape.services.transaction.domain.service.TransactionStatusEventPublisher; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.jboss.logging.Logger; + +/** + * Kafka implementation of TransactionStatusEventPublisher. + * Publishes transaction status update events to Kafka topics. + */ +@ApplicationScoped +public class KafkaTransactionStatusPublisher implements TransactionStatusEventPublisher { + + private static final Logger LOGGER = Logger.getLogger(KafkaTransactionStatusPublisher.class); + + private final Emitter> statusUpdatedEmitter; + + /** + * Constructor for KafkaTransactionStatusPublisher. + * + * @param statusUpdatedEmitter the emitter for transaction status updated events + */ + @Inject + public KafkaTransactionStatusPublisher( + @Channel("transaction-status-producer") + Emitter> statusUpdatedEmitter + ) { + this.statusUpdatedEmitter = statusUpdatedEmitter; + } + + @Override + public void publishStatusUpdated(TransactionStatusUpdatedEvent event) { + String key = event.getPayload().getTransactionExternalId(); + + LOGGER.infof("Publishing TransactionStatusUpdatedEvent with key: %s, status: %s", + key, event.getPayload().getNewStatus()); + + statusUpdatedEmitter.send(Record.of(key, event)) + .whenComplete((result, error) -> { + if (error != null) { + LOGGER.errorf(error, + "Failed to publish TransactionStatusUpdatedEvent with key: %s", key); + } else { + LOGGER.infof("Successfully published TransactionStatusUpdatedEvent with key: %s", key); + } + }); + } + +} diff --git a/ms-anti-fraud/src/main/resources/application-local.yml b/ms-anti-fraud/src/main/resources/application-local.yml new file mode 100644 index 0000000000..99fdd5bd32 --- /dev/null +++ b/ms-anti-fraud/src/main/resources/application-local.yml @@ -0,0 +1,60 @@ +# This configuration is used for local development environment. +# Profile: local (activate with -Dquarkus.profile=local) + +# Quarkus Configuration +quarkus: + devservices: + enabled: false + + # Logging Configuration + log: + level: INFO + category: + "com.yape": + level: DEBUG + "io.smallrye.reactive.messaging": + level: DEBUG + +# Kafka / Event Streaming Configuration +kafka: + bootstrap: + servers: "${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}" + schema: + registry: + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" + +# MicroProfile Reactive Messaging Configuration +mp: + messaging: + outgoing: + transaction-status-producer: + connector: smallrye-kafka + topic: transaction.status + key: + serializer: org.apache.kafka.common.serialization.StringSerializer + value: + serializer: io.confluent.kafka.serializers.KafkaAvroSerializer + schema: + registry: + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" + + incoming: + transaction-created-consumer: + connector: smallrye-kafka + topic: transaction.created + group: + id: ms-anti-fraud-group + auto: + offset: + reset: earliest + failure-strategy: ignore + key: + deserializer: org.apache.kafka.common.serialization.StringDeserializer + value: + deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer + schema: + registry: + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" + specific: + avro: + reader: true diff --git a/ms-anti-fraud/src/main/resources/application.yml b/ms-anti-fraud/src/main/resources/application.yml new file mode 100644 index 0000000000..03c6ec9547 --- /dev/null +++ b/ms-anti-fraud/src/main/resources/application.yml @@ -0,0 +1,16 @@ +quarkus: + application: + name: ms-anti-fraud + smallrye-health: + root-path: /health + ui: + enabled: false + http: + root-path: /ms-anti-fraud + port: 18081 + profile: local + +info: + project: + artifact: ${project.artifactId} + version: ${project.version} \ No newline at end of file diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCaseTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCaseTest.java new file mode 100644 index 0000000000..f7891c9126 --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCaseTest.java @@ -0,0 +1,206 @@ +package com.yape.services.transaction.application.usecase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.domain.model.ValidationResult; +import com.yape.services.transaction.domain.service.AntiFraudValidationService; +import com.yape.services.transaction.domain.service.TransactionStatusEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.events.TransactionCreatedPayload; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.enums.TransactionStatus; +import java.math.BigDecimal; +import java.sql.Timestamp; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link ValidateTransactionUseCase}. + */ +@ExtendWith(MockitoExtension.class) +class ValidateTransactionUseCaseTest { + + @Mock + AntiFraudValidationService antiFraudValidationService; + @Mock + TransactionStatusEventPublisher eventPublisher; + + @InjectMocks + ValidateTransactionUseCase useCase; + + @Test + @DisplayName("should approve transaction and publish APPROVED event when validation passes") + void shouldApproveTransactionAndPublishApprovedEventWhenValidationPasses() { + // Arrange + String transactionExternalId = "tx-123"; + String value = "100.00"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata("req-1"); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + when(antiFraudValidationService.validate(new BigDecimal(value))) + .thenReturn(ValidationResult.approved()); + + // Act + useCase.execute(event); + + // Assert + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionStatusUpdatedEvent.class); + verify(eventPublisher).publishStatusUpdated(captor.capture()); + TransactionStatusUpdatedEvent publishedEvent = captor.getValue(); + assertEquals(TransactionStatus.APPROVED, publishedEvent.getPayload().getNewStatus()); + assertTrue(publishedEvent.getPayload().getValidationResult().getIsValid()); + assertNull(publishedEvent.getPayload().getValidationResult().getRuleCode()); + assertEquals(transactionExternalId, publishedEvent.getPayload().getTransactionExternalId()); + } + + @Test + @DisplayName("should reject transaction and publish REJECTED event when validation fails") + void shouldRejectTransactionAndPublishRejectedEventWhenValidationFails() { + // Arrange + String transactionExternalId = "tx-456"; + String value = "1000.01"; + String ruleCode = "MAX_AMOUNT_EXCEEDED"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata("req-2"); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + when(antiFraudValidationService.validate(new BigDecimal(value))) + .thenReturn(ValidationResult.rejected(ruleCode)); + + // Act + useCase.execute(event); + + // Assert + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionStatusUpdatedEvent.class); + verify(eventPublisher).publishStatusUpdated(captor.capture()); + TransactionStatusUpdatedEvent publishedEvent = captor.getValue(); + assertEquals(TransactionStatus.REJECTED, publishedEvent.getPayload().getNewStatus()); + assertFalse(publishedEvent.getPayload().getValidationResult().getIsValid()); + assertEquals(ruleCode, publishedEvent.getPayload().getValidationResult().getRuleCode()); + assertEquals(transactionExternalId, publishedEvent.getPayload().getTransactionExternalId()); + } + + @Test + @DisplayName("should handle null ruleCode in rejected validation result") + void shouldHandleNullRuleCodeInRejectedValidationResult() { + // Arrange + String transactionExternalId = "tx-789"; + String value = "2000.00"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata("req-3"); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + when(antiFraudValidationService.validate(new BigDecimal(value))) + .thenReturn(ValidationResult.rejected(null)); + + // Act + useCase.execute(event); + + // Assert + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionStatusUpdatedEvent.class); + verify(eventPublisher).publishStatusUpdated(captor.capture()); + TransactionStatusUpdatedEvent publishedEvent = captor.getValue(); + assertEquals(TransactionStatus.REJECTED, publishedEvent.getPayload().getNewStatus()); + assertFalse(publishedEvent.getPayload().getValidationResult().getIsValid()); + assertNull(publishedEvent.getPayload().getValidationResult().getRuleCode()); + } + + @Test + @DisplayName("should use requestId from original event metadata in published event") + void shouldUseRequestIdFromOriginalEventMetadataInPublishedEvent() { + // Arrange + String transactionExternalId = "tx-reqid"; + String value = "50.00"; + String requestId = "req-xyz"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata(requestId); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + when(antiFraudValidationService.validate(new BigDecimal(value))) + .thenReturn(ValidationResult.approved()); + + // Act + useCase.execute(event); + + // Assert + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionStatusUpdatedEvent.class); + verify(eventPublisher).publishStatusUpdated(captor.capture()); + TransactionStatusUpdatedEvent publishedEvent = captor.getValue(); + assertEquals(requestId, publishedEvent.getMetadata().getRequestId()); + } + + @Test + @DisplayName("should throw NumberFormatException if payload value is not a valid number") + void shouldThrowNumberFormatExceptionIfPayloadValueIsNotValidNumber() { + // Arrange + String transactionExternalId = "tx-invalid"; + String value = "not-a-number"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata("req-invalid"); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + + // Act & Assert + assertThrows(NumberFormatException.class, () -> useCase.execute(event)); + verifyNoInteractions(eventPublisher); + } + + EventMetadata buildMetadata(String requestId) { + return EventMetadata.newBuilder() + .setEventId("event-123") + .setEventType("TransactionCreated") + .setEventTimestamp(new Timestamp(System.currentTimeMillis()).toString()) + .setSource("ms-transaction") + .setVersion("1.0") + .setRequestId(requestId) + .build(); + } + + TransactionCreatedPayload buildTransactionCreatedPayload( + String transactionExternalId, + String value + ) { + return TransactionCreatedPayload.newBuilder() + .setTransactionExternalId(transactionExternalId) + .setAccountExternalIdDebit("debit-acc-1") + .setAccountExternalIdCredit("credit-acc-1") + .setTransferTypeId(1) + .setValue(value) + .setStatus(com.yape.services.transaction.events.enums.TransactionStatus.PENDING) + .setCreatedAt(new Timestamp(System.currentTimeMillis()).toString()) + .build(); + } +} diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/model/ValidationResultTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/model/ValidationResultTest.java new file mode 100644 index 0000000000..e8709374b0 --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/model/ValidationResultTest.java @@ -0,0 +1,35 @@ +package com.yape.services.transaction.domain.model; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the ValidationResult class. + */ +class ValidationResultTest { + + @Test + void approved_shouldReturnValidTrueAndNullRuleCode() { + // Arrange + + // Act + ValidationResult result = ValidationResult.approved(); + + // Assert + Assertions.assertTrue(result.isValid()); + Assertions.assertNull(result.getRuleCode()); + } + + @Test + void rejected_shouldReturnValidFalseAndRuleCode() { + // Arrange + String ruleCode = "MAX_AMOUNT_EXCEEDED"; + + // Act + ValidationResult result = ValidationResult.rejected(ruleCode); + + // Assert + Assertions.assertFalse(result.isValid()); + Assertions.assertEquals(ruleCode, result.getRuleCode()); + } +} diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/service/AntiFraudValidationServiceTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/service/AntiFraudValidationServiceTest.java new file mode 100644 index 0000000000..eaf6a4f483 --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/service/AntiFraudValidationServiceTest.java @@ -0,0 +1,44 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.common.util.Constants; +import com.yape.services.transaction.domain.model.ValidationResult; +import java.math.BigDecimal; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for AntiFraudValidationService. + */ +class AntiFraudValidationServiceTest { + + private final AntiFraudValidationService service = new AntiFraudValidationService(); + + @Test + @DisplayName("Should approve transaction when amount is below or equal to max limit") + void shouldApproveTransactionWhenAmountIsBelowOrEqualToMax() { + // Arrange + BigDecimal amount = new BigDecimal("500"); + + // Act + ValidationResult result = service.validate(amount); + + // Assert + Assertions.assertTrue(result.isValid()); + Assertions.assertNull(result.getRuleCode()); + } + + @Test + @DisplayName("Should reject transaction when amount exceeds max limit") + void shouldRejectTransactionWhenAmountExceedsMax() { + // Arrange + BigDecimal amount = new BigDecimal("1000.01"); + + // Act + ValidationResult result = service.validate(amount); + + // Assert + Assertions.assertFalse(result.isValid()); + Assertions.assertEquals(Constants.RULE_MAX_AMOUNT_EXCEEDED, result.getRuleCode()); + } +} diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumerTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumerTest.java new file mode 100644 index 0000000000..a0744d52b5 --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumerTest.java @@ -0,0 +1,80 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.application.usecase.ValidateTransactionUseCase; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link KafkaTransactionCreatedConsumer}. + */ +@ExtendWith(MockitoExtension.class) +class KafkaTransactionCreatedConsumerTest { + + @Mock + ValidateTransactionUseCase validateTransactionUseCase; + + @InjectMocks + KafkaTransactionCreatedConsumer consumer; + + @Mock + Record kafkaRecord; + + @Mock + TransactionCreatedEvent event; + + @Test + @DisplayName("should delegate event to ValidateTransactionUseCase when event is received") + void shouldDelegateEventToValidateTransactionUseCaseWhenEventIsReceived() { + // Arrange + when(kafkaRecord.key()).thenReturn("key-1"); + when(kafkaRecord.value()).thenReturn(event); + + // Act + consumer.consume(kafkaRecord); + + // Assert + org.mockito.Mockito.verify(validateTransactionUseCase, org.mockito.Mockito.times(1)) + .execute(event); + } + + @Test + @DisplayName("should log and rethrow exception if ValidateTransactionUseCase throws") + void shouldLogAndRethrowExceptionIfValidateTransactionUseCaseThrows() { + // Arrange + when(kafkaRecord.key()).thenReturn("key-2"); + when(kafkaRecord.value()).thenReturn(event); + RuntimeException ex = new RuntimeException("validation failed"); + org.mockito.Mockito.doThrow(ex).when(validateTransactionUseCase).execute(event); + + // Act & Assert + assertEquals( + ex, + assertThrows(RuntimeException.class, () -> consumer.consume(kafkaRecord)) + ); + } + + @Test + @DisplayName("should handle null event value gracefully") + void shouldHandleNullEventValueGracefully() { + // Arrange + when(kafkaRecord.key()).thenReturn("key-3"); + when(kafkaRecord.value()).thenReturn(null); + + // Act + consumer.consume(kafkaRecord); + + // Assert + org.mockito.Mockito.verify(validateTransactionUseCase, org.mockito.Mockito.times(1)) + .execute(null); + } +} diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisherTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisherTest.java new file mode 100644 index 0000000000..7e144f964e --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisherTest.java @@ -0,0 +1,76 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import com.yape.services.transaction.events.enums.TransactionStatus; +import io.smallrye.reactive.messaging.kafka.Record; +import java.util.concurrent.CompletableFuture; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link KafkaTransactionStatusPublisher}. + */ +@ExtendWith(MockitoExtension.class) +class KafkaTransactionStatusPublisherTest { + + @Mock + Emitter> statusUpdatedEmitter; + + @InjectMocks + KafkaTransactionStatusPublisher publisher; + + @Mock + TransactionStatusUpdatedEvent event; + @Mock + TransactionStatusUpdatedPayload payload; + + @Test + @DisplayName("should publish event and log success when emitter sends successfully") + void shouldPublishEventAndLogSuccess() { + // Arrange + String transactionId = "tx-123"; + TransactionStatus newStatus = TransactionStatus.APPROVED; + when(event.getPayload()).thenReturn(payload); + when(payload.getTransactionExternalId()).thenReturn(transactionId); + when(payload.getNewStatus()).thenReturn(newStatus); + CompletableFuture future = new CompletableFuture<>(); + when(statusUpdatedEmitter.send(any(Record.class))).thenReturn(future); + + // Act + publisher.publishStatusUpdated(event); + future.complete(null); + + // Assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(Record.class); + verify(statusUpdatedEmitter).send(captor.capture()); + Record captorValue = captor.getValue(); + assertEquals(transactionId, captorValue.key()); + assertSame(event, captorValue.value()); + } + + @Test + @DisplayName("should handle null payload gracefully") + void shouldHandleNullPayloadGracefully() { + // Arrange + when(event.getPayload()).thenReturn(null); + + // Act & Assert + assertThrows(NullPointerException.class, () -> publisher.publishStatusUpdated(event)); + } +} diff --git a/ms-transaction/.dockerignore b/ms-transaction/.dockerignore new file mode 100644 index 0000000000..da14c708af --- /dev/null +++ b/ms-transaction/.dockerignore @@ -0,0 +1,22 @@ +# Ignore IDE files +.idea/ +*.iml +.vscode/ +.eclipse/ + +# Ignore build output (we build inside container) +target/ + +# Ignore git +.git/ +.gitignore + +# Ignore documentation +*.md +docs/ + +# Ignore test resources that aren't needed +src/test/resources/ + +# Ignore local config +.claude/ diff --git a/ms-transaction/.gitignore b/ms-transaction/.gitignore new file mode 100644 index 0000000000..1cf10f5273 --- /dev/null +++ b/ms-transaction/.gitignore @@ -0,0 +1,50 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ +# TLS Certificates +.certs/ + +# Maven Wrapper +mvnw +mvnw.cmd +.mvn diff --git a/ms-transaction/README.md b/ms-transaction/README.md new file mode 100644 index 0000000000..29bc064b43 --- /dev/null +++ b/ms-transaction/README.md @@ -0,0 +1,62 @@ +# ms-transaction + +This project uses Quarkus, the Supersonic Subatomic Java Framework. + +If you want to learn more about Quarkus, please visit its website: . + +## Running the application in dev mode + +You can run your application in dev mode that enables live coding using: + +```shell script +./mvnw quarkus:dev +``` + +> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at . + +## Packaging and running the application + +The application can be packaged using: + +```shell script +./mvnw package +``` + +It produces the `quarkus-run.jar` file in the `target/quarkus-app/` directory. +Be aware that it’s not an _über-jar_ as the dependencies are copied into the `target/quarkus-app/lib/` directory. + +The application is now runnable using `java -jar target/quarkus-app/quarkus-run.jar`. + +If you want to build an _über-jar_, execute the following command: + +```shell script +./mvnw package -Dquarkus.package.jar.type=uber-jar +``` + +The application, packaged as an _über-jar_, is now runnable using `java -jar target/*-runner.jar`. + +## Creating a native executable + +You can create a native executable using: + +```shell script +./mvnw package -Dnative +``` + +Or, if you don't have GraalVM installed, you can run the native executable build in a container using: + +```shell script +./mvnw package -Dnative -Dquarkus.native.container-build=true +``` + +You can then execute your native executable with: `./target/ms-transaction-1.0.0-SNAPSHOT-runner` + +If you want to learn more about building native executables, please consult . + +## Provided Code + +### REST + +Easily start your REST Web Services + +[Related guide section...](https://quarkus.io/guides/getting-started-reactive#reactive-jax-rs-resources) diff --git a/ms-transaction/checkstyle-suppressions.xml b/ms-transaction/checkstyle-suppressions.xml new file mode 100644 index 0000000000..2161f901d9 --- /dev/null +++ b/ms-transaction/checkstyle-suppressions.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/ms-transaction/checkstyle.xml b/ms-transaction/checkstyle.xml new file mode 100644 index 0000000000..745a2ada9e --- /dev/null +++ b/ms-transaction/checkstyle.xml @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ms-transaction/devops/docker/Dockerfile.jvm b/ms-transaction/devops/docker/Dockerfile.jvm new file mode 100644 index 0000000000..68aa749985 --- /dev/null +++ b/ms-transaction/devops/docker/Dockerfile.jvm @@ -0,0 +1,32 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./mvnw package +# +# Then, build the image with: +# +# docker build -f devops/docker/Dockerfile.jvm -t quarkus/ms-transaction-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 18080:18080 quarkus/ms-transaction-jvm +# +### +FROM registry.access.redhat.com/ubi9/openjdk-21:1.23 + +ENV LANGUAGE='en_US:en' + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 target/quarkus-app/*.jar /deployments/ +COPY --chown=185 target/quarkus-app/app/ /deployments/app/ +COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 18080 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] diff --git a/ms-transaction/devops/docker/Dockerfile.multistage b/ms-transaction/devops/docker/Dockerfile.multistage new file mode 100644 index 0000000000..44272cd221 --- /dev/null +++ b/ms-transaction/devops/docker/Dockerfile.multistage @@ -0,0 +1,40 @@ +#### +# Multi-stage Dockerfile for ms-transaction +# Compiles the application and creates a minimal runtime image +#### + +# Stage 1: Build +FROM maven:3.9.9-eclipse-temurin-21 AS builder + +WORKDIR /build + +# Copy pom.xml and checkstyle config first for better caching +COPY pom.xml checkstyle.xml checkstyle-suppressions.xml ./ + +# Download dependencies (cached layer) +RUN mvn dependency:go-offline -B + +# Copy source code +COPY src ./src + +# Build the application +RUN mvn package -DskipTests -B + +# Stage 2: Runtime +FROM registry.access.redhat.com/ubi9/openjdk-21:1.23 + +ENV LANGUAGE='en_US:en' + +# Copy the built application from builder stage +COPY --from=builder --chown=185 /build/target/quarkus-app/lib/ /deployments/lib/ +COPY --from=builder --chown=185 /build/target/quarkus-app/*.jar /deployments/ +COPY --from=builder --chown=185 /build/target/quarkus-app/app/ /deployments/app/ +COPY --from=builder --chown=185 /build/target/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 18080 +USER 185 + +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] diff --git a/ms-transaction/pom.xml b/ms-transaction/pom.xml new file mode 100644 index 0000000000..97d171c74f --- /dev/null +++ b/ms-transaction/pom.xml @@ -0,0 +1,389 @@ + + + 4.0.0 + + + 3.14.1 + 21 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.31.1 + true + 3.5.4 + 7.9.0 + 5.10.0 + 0.8.12 + 3.6.0 + 12.1.2 + 4.8.6.6 + 1.18.42 + 3.43.0 + 1.6.3 + 0.2.0 + + + com.yape.services + ms-transaction + 1.0.0-SNAPSHOT + quarkus + + MS Transaction + Microservice for handling transactions + 2026 + + + Yape + https://www.yape.com.pe + + + + + jcarripa + Juda Carrillo + jbcp2006@gmail.com + America/Lima + + Tech Lead + Senior Backend Developer + + + + + + + confluent + https://packages.confluent.io/maven/ + + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-config-yaml + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-hibernate-validator + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-hibernate-orm-panache + + + + + io.quarkus + quarkus-flyway + + + + + org.redisson + redisson-quarkus-30 + ${redisson.version} + + + + + io.quarkus + quarkus-messaging-kafka + + + + io.quarkus + quarkus-avro + + + io.quarkus + quarkus-confluent-registry-avro + + + io.confluent + kafka-avro-serializer + ${confluent.version} + + + + + io.quarkus + quarkus-smallrye-graphql + + + + + io.quarkus + quarkus-junit + test + + + io.quarkus + quarkus-junit5-mockito + test + + + io.rest-assured + rest-assured + test + + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + + + + + + + + io.github.kobylynskyi + graphql-codegen-maven-plugin + ${graphql.codegen.version} + + + + generate + + + + + + ${project.basedir}/src/main/resources/graphql-client + .*\.graphqls$ + true + + ${project.build.directory}/generated-sources/graphql + true + com.yape.services.transaction.graphql.model + com.yape.services.transaction.graphql.api + true + true + @jakarta.validation.constraints.NotNull + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok-mapstruct-binding + ${lombok.mapstruct.binding} + + + + -Amapstruct.defaultComponentModel=jakarta-cdi + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + @{argLine} + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + @{argLine} + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + **/transaction/graphql/** + **/persistence/entity/** + **/TransferTypeMapperImpl.* + **/GraphqlTransactionMapperImpl.* + + + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle-plugin.version} + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + + checkstyle.xml + checkstyle-suppressions.xml + true + true + true + + + + validate + validate + + check + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-plugin.version} + + spotbugs-exclude.xml + Max + Low + + + + spotbugs-check + verify + + check + + + + + + + + + + native + + + native + + + + false + false + true + + + + diff --git a/ms-transaction/spotbugs-exclude.xml b/ms-transaction/spotbugs-exclude.xml new file mode 100644 index 0000000000..150e3d46e1 --- /dev/null +++ b/ms-transaction/spotbugs-exclude.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc new file mode 100644 index 0000000000..dd825a7498 --- /dev/null +++ b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc @@ -0,0 +1,111 @@ +{ + "type": "record", + "name": "TransactionCreatedEvent", + "namespace": "com.yape.services.transaction.events", + "doc": "Emitted event when a new transaction is created. Published to the topic 'transaction.created' for the Anti-Fraud service to validate the transaction.", + "fields": [ + { + "name": "metadata", + "type": { + "type": "record", + "name": "EventMetadata", + "namespace": "com.yape.services.common.events", + "doc": "Standard metadata for all system events", + "fields": [ + { + "name": "eventId", + "type": "string", + "doc": "Unique identifier of the event (UUID v4)" + }, + { + "name": "eventType", + "type": "string", + "doc": "Type of the event (e.g., TRANSACTION_CREATED, TRANSACTION_STATUS_UPDATED)" + }, + { + "name": "eventTimestamp", + "type": "string", + "doc": "Timestamp of event creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + }, + { + "name": "source", + "type": "string", + "doc": "Service originating the event (e.g., ms-transaction, ms-anti-fraud)" + }, + { + "name": "version", + "type": "string", + "default": "1.0.0", + "doc": "Version of the event schema" + }, + { + "name": "requestId", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Original Request-Id of the HTTP request that triggered the event" + } + ] + }, + "doc": "Event metadata" + }, + { + "name": "payload", + "type": { + "type": "record", + "name": "TransactionCreatedPayload", + "doc": "Payload of the created transaction", + "fields": [ + { + "name": "transactionExternalId", + "type": "string", + "doc": "Unique external identifier of the transaction (UUID v4)" + }, + { + "name": "accountExternalIdDebit", + "type": "string", + "doc": "Debit account external identifier" + }, + { + "name": "accountExternalIdCredit", + "type": "string", + "doc": "Credit account external identifier" + }, + { + "name": "transferTypeId", + "type": "int", + "doc": "Transfer type identifier." + }, + { + "name": "value", + "type": "string", + "doc": "Transaction monetary value as string to preserve precision" + }, + { + "name": "status", + "type": { + "type": "enum", + "name": "TransactionStatus", + "namespace": "com.yape.services.transaction.events.enums", + "doc": "Possible statuses of a financial transaction", + "symbols": [ + "PENDING", + "APPROVED", + "REJECTED" + ] + }, + "doc": "Current status of the transaction (always PENDING upon creation)" + }, + { + "name": "createdAt", + "type": "string", + "doc": "Timestamp of transaction creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + } + ] + }, + "doc": "Payload of the created transaction" + } + ] +} \ No newline at end of file diff --git a/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc new file mode 100644 index 0000000000..049e9b81b1 --- /dev/null +++ b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -0,0 +1,126 @@ +{ + "type": "record", + "name": "TransactionStatusUpdatedEvent", + "namespace": "com.yape.services.transaction.events", + "doc": "Emitted event when Anti-Fraud service updates the status of a transaction. Published to the topic 'transaction.status' for ms-transaction to update the status in the database.", + "fields": [ + { + "name": "metadata", + "type": { + "type": "record", + "name": "EventMetadata", + "namespace": "com.yape.services.common.events", + "doc": "Standard metadata for all system events", + "fields": [ + { + "name": "eventId", + "type": "string", + "doc": "Unique identifier of the event (UUID v4)" + }, + { + "name": "eventType", + "type": "string", + "doc": "Type of the event (e.g., TRANSACTION_CREATED, TRANSACTION_STATUS_UPDATED)" + }, + { + "name": "eventTimestamp", + "type": "string", + "doc": "Timestamp of event creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + }, + { + "name": "source", + "type": "string", + "doc": "Service originating the event (e.g., ms-transaction, ms-anti-fraud)" + }, + { + "name": "version", + "type": "string", + "default": "1.0.0", + "doc": "Version of the event schema" + }, + { + "name": "requestId", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Original Request-Id of the HTTP request that triggered the event" + } + ] + }, + "doc": "Event metadata" + }, + { + "name": "payload", + "type": { + "type": "record", + "name": "TransactionStatusUpdatedPayload", + "doc": "Payload with the update data", + "fields": [ + { + "name": "transactionExternalId", + "type": "string", + "doc": "Unique external identifier of the transaction (UUID v4)" + }, + { + "name": "previousStatus", + "type": { + "type": "enum", + "name": "TransactionStatus", + "namespace": "com.yape.services.transaction.events.enums", + "doc": "Possible statuses of a financial transaction", + "symbols": [ + "PENDING", + "APPROVED", + "REJECTED" + ] + }, + "doc": "Previous status of the transaction" + }, + { + "name": "newStatus", + "type": "com.yape.services.transaction.events.enums.TransactionStatus", + "doc": "New status of the transaction" + }, + { + "name": "value", + "type": "string", + "doc": "Monetary value of the transaction as a string to preserve precision" + }, + { + "name": "validationResult", + "type": { + "type": "record", + "name": "ValidationResult", + "doc": "Details of the anti-fraud validation result", + "fields": [ + { + "name": "isValid", + "type": "boolean", + "doc": "Indicates if the transaction passed the anti-fraud validation" + }, + { + "name": "ruleCode", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Code of the applied rule (e.g., MAX_AMOUNT_EXCEEDED)" + } + ] + }, + "doc": "Details of the anti-fraud validation result" + }, + { + "name": "processedAt", + "type": "string", + "doc": "Timestamp when the validation was processed in ISO 8601 format" + } + ] + }, + "doc": "Payload with the update data" + } + ] +} \ No newline at end of file diff --git a/ms-transaction/src/main/java/com/yape/services/expose/graphql/MutationResolverImpl.java b/ms-transaction/src/main/java/com/yape/services/expose/graphql/MutationResolverImpl.java new file mode 100644 index 0000000000..250a9062b2 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/expose/graphql/MutationResolverImpl.java @@ -0,0 +1,56 @@ +package com.yape.services.expose.graphql; + +import com.yape.services.shared.util.Constants; +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.application.usecase.CreateTransactionUseCase; +import com.yape.services.transaction.graphql.api.MutationResolver; +import com.yape.services.transaction.graphql.model.CreateTransaction; +import com.yape.services.transaction.graphql.model.Transaction; +import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import jakarta.inject.Inject; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Mutation; + +/** + * GraphQL resolver for transaction-related operations. + */ +@GraphQLApi +public class MutationResolverImpl implements MutationResolver { + + private final CreateTransactionUseCase createTransactionUseCase; + private final CurrentVertxRequest currentVertxRequest; + + /** + * Constructor for TransactionResolver. + * + * @param createTransactionUseCase the service handling transaction logic + * @param currentVertxRequest the current Vert.x request context + */ + @Inject + public MutationResolverImpl(CreateTransactionUseCase createTransactionUseCase, + CurrentVertxRequest currentVertxRequest) { + this.createTransactionUseCase = createTransactionUseCase; + this.currentVertxRequest = currentVertxRequest; + } + + /** + * Creates a new transaction. + * + * @param input the input data for creating a transaction + * @return the created transaction details + */ + @Mutation("createTransaction") + @Override + public Transaction createTransaction(@NotNull CreateTransaction input) { + var req = currentVertxRequest.getCurrent().request(); + + String authHeader = req.getHeader("Authorization"); + String requestId = req.getHeader(Constants.REQUEST_ID); + String requestDate = req.getHeader(Constants.REQUEST_DATE); + + RequestMetaData metadata = new RequestMetaData(authHeader, requestId, requestDate); + return createTransactionUseCase.execute(input, metadata); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/expose/graphql/QueryResolverImpl.java b/ms-transaction/src/main/java/com/yape/services/expose/graphql/QueryResolverImpl.java new file mode 100644 index 0000000000..57f72aa337 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/expose/graphql/QueryResolverImpl.java @@ -0,0 +1,48 @@ +package com.yape.services.expose.graphql; + +import com.yape.services.transaction.application.usecase.GetTransactionUseCase; +import com.yape.services.transaction.application.usecase.GetTransferTypesUseCase; +import com.yape.services.transaction.graphql.api.QueryResolver; +import com.yape.services.transaction.graphql.model.Transaction; +import com.yape.services.transaction.graphql.model.TransferType; +import jakarta.inject.Inject; +import java.util.List; +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Name; +import org.eclipse.microprofile.graphql.Query; + +/** + * GraphQL resolver for transaction-related queries. + */ +@GraphQLApi +public class QueryResolverImpl implements QueryResolver { + + private final GetTransferTypesUseCase getTransferTypesUseCase; + private final GetTransactionUseCase getTransactionUseCase; + + /** + * Constructor for TransactionQueryResolver. + * + * @param getTransferTypesUseCase the service handling transfer type logic + * @param getTransactionUseCase the service handling transaction retrieval logic + */ + @Inject + public QueryResolverImpl(GetTransferTypesUseCase getTransferTypesUseCase, + GetTransactionUseCase getTransactionUseCase) { + this.getTransferTypesUseCase = getTransferTypesUseCase; + this.getTransactionUseCase = getTransactionUseCase; + } + + @Query("transaction") + @Override + public Transaction transaction(@Name("transactionExternalId") String transactionExternalId) { + return getTransactionUseCase.execute(transactionExternalId); + } + + @Query("transferTypes") + @Override + public List transferTypes() { + return getTransferTypesUseCase.execute(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/exception/BusinessException.java b/ms-transaction/src/main/java/com/yape/services/shared/exception/BusinessException.java new file mode 100644 index 0000000000..72fce551f4 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/exception/BusinessException.java @@ -0,0 +1,51 @@ +package com.yape.services.shared.exception; + +import java.io.Serial; +import lombok.Getter; + +/** + * Base exception for business-related errors in the application. + * All custom exceptions should extend this class to ensure consistent error handling. + */ +@Getter +public class BusinessException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final ErrorCode errorCode; + + /** + * Constructs a new BusinessException with the specified error code and message. + * + * @param errorCode the error code representing the type of error + * @param message the detail message + */ + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + /** + * Constructs a new BusinessException with the specified error code, message, and cause. + * + * @param errorCode the error code representing the type of error + * @param message the detail message + * @param cause the cause of the exception + */ + public BusinessException(ErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + /** + * Constructs a new BusinessException with the specified error code using its default message. + * + * @param errorCode the error code representing the type of error + */ + public BusinessException(ErrorCode errorCode) { + super(errorCode.getDefaultMessage()); + this.errorCode = errorCode; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/exception/ErrorCode.java b/ms-transaction/src/main/java/com/yape/services/shared/exception/ErrorCode.java new file mode 100644 index 0000000000..6338bf3aad --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/exception/ErrorCode.java @@ -0,0 +1,39 @@ +package com.yape.services.shared.exception; + +import lombok.Getter; + +/** + * Enumeration of error codes for GraphQL responses. + * Provides standardized error classification across the application. + */ +@Getter +public enum ErrorCode { + + // Validation errors (400) + VALIDATION_ERROR("VALIDATION_ERROR", "Validation failed"), + INVALID_INPUT("INVALID_INPUT", "Invalid input provided"), + INVALID_FORMAT("INVALID_FORMAT", "Invalid data format"), + + // Not found errors (404) + RESOURCE_NOT_FOUND("RESOURCE_NOT_FOUND", "Resource not found"), + TRANSACTION_NOT_FOUND("TRANSACTION_NOT_FOUND", "Transaction not found"), + TRANSFER_TYPE_NOT_FOUND("TRANSFER_TYPE_NOT_FOUND", "Transfer type not found"), + TRANSACTION_STATUS_NOT_FOUND("TRANSACTION_STATUS_NOT_FOUND", "Transaction status not found"), + + // Business logic errors (422) + BUSINESS_ERROR("BUSINESS_ERROR", "Business rule violation"), + AMOUNT_BELOW_MINIMUM("AMOUNT_BELOW_MINIMUM", "Amount is below the minimum allowed"), + + // Internal errors (500) + INTERNAL_ERROR("INTERNAL_ERROR", "Internal server error"), + CONFIGURATION_ERROR("CONFIGURATION_ERROR", "Configuration error"); + + private final String code; + private final String defaultMessage; + + ErrorCode(String code, String defaultMessage) { + this.code = code; + this.defaultMessage = defaultMessage; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/exception/ResourceNotFoundException.java b/ms-transaction/src/main/java/com/yape/services/shared/exception/ResourceNotFoundException.java new file mode 100644 index 0000000000..b4fd2147c6 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/exception/ResourceNotFoundException.java @@ -0,0 +1,55 @@ +package com.yape.services.shared.exception; + +import java.io.Serial; +import lombok.Getter; + +/** + * Exception thrown when a requested resource cannot be found. + * Used for entities that don't exist in the database or cache. + */ +@Getter +public class ResourceNotFoundException extends BusinessException { + + @Serial + private static final long serialVersionUID = 1L; + + private final String resourceType; + private final String resourceId; + + /** + * Constructs a ResourceNotFoundException with a custom message. + * + * @param message the detail message + */ + public ResourceNotFoundException(String message) { + super(ErrorCode.RESOURCE_NOT_FOUND, message); + this.resourceType = null; + this.resourceId = null; + } + + /** + * Constructs a ResourceNotFoundException for a specific resource. + * + * @param errorCode the specific error code + * @param resourceType the type of resource not found + * @param resourceId the identifier of the resource + */ + public ResourceNotFoundException(ErrorCode errorCode, String resourceType, String resourceId) { + super(errorCode, String.format("%s not found with ID: %s", resourceType, resourceId)); + this.resourceType = resourceType; + this.resourceId = resourceId; + } + + /** + * Constructs a ResourceNotFoundException with a specific error code and message. + * + * @param errorCode the specific error code + * @param message the detail message + */ + public ResourceNotFoundException(ErrorCode errorCode, String message) { + super(errorCode, message); + this.resourceType = null; + this.resourceId = null; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/exception/ValidationException.java b/ms-transaction/src/main/java/com/yape/services/shared/exception/ValidationException.java new file mode 100644 index 0000000000..41e1f079c7 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/exception/ValidationException.java @@ -0,0 +1,51 @@ +package com.yape.services.shared.exception; + +import java.io.Serial; +import lombok.Getter; + +/** + * Exception thrown when input validation fails. + * Used for invalid parameters, missing required fields, or format errors. + */ +@Getter +public class ValidationException extends BusinessException { + + @Serial + private static final long serialVersionUID = 1L; + + private final String fieldName; + + /** + * Constructs a ValidationException with a custom message. + * + * @param message the detail message describing the validation error + */ + public ValidationException(String message) { + super(ErrorCode.VALIDATION_ERROR, message); + this.fieldName = null; + } + + /** + * Constructs a ValidationException with a specific error code and message. + * + * @param errorCode the specific error code + * @param message the detail message + */ + public ValidationException(ErrorCode errorCode, String message) { + super(errorCode, message); + this.fieldName = null; + } + + /** + * Constructs a ValidationException for a specific field. + * + * @param errorCode the specific error code + * @param fieldName the name of the field that failed validation + * @param message the detail message + */ + public ValidationException(ErrorCode errorCode, String fieldName, String message) { + super(errorCode, message); + this.fieldName = fieldName; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/util/CacheKeyUtils.java b/ms-transaction/src/main/java/com/yape/services/shared/util/CacheKeyUtils.java new file mode 100644 index 0000000000..bff35255e5 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/util/CacheKeyUtils.java @@ -0,0 +1,22 @@ +package com.yape.services.shared.util; + +/** + * Utility class for cache key operations. + */ +public final class CacheKeyUtils { + + private CacheKeyUtils() { + } + + /** + * Builds a cache key with prefix and value. + * + * @param prefix the key prefix + * @param value the value to append + * @return the cache key + */ + public static String buildKey(String prefix, String value) { + return prefix + value; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/util/Constants.java b/ms-transaction/src/main/java/com/yape/services/shared/util/Constants.java new file mode 100644 index 0000000000..c88e3ba82b --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/util/Constants.java @@ -0,0 +1,18 @@ +package com.yape.services.shared.util; + +/** + * Constants used across the application. + */ +public class Constants { + + private Constants() { + } + + public static final String REQUEST_ID = "Request-ID"; + public static final String REQUEST_DATE = "Request-Date"; + + public static final String TRANSACTION_STATUS_PENDING = "PENDING"; + public static final String TRANSACTION_STATUS_APPROVED = "APPROVED"; + public static final String TRANSACTION_STATUS_REJECTED = "REJECTED"; + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommand.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommand.java new file mode 100644 index 0000000000..6e0bee1a57 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommand.java @@ -0,0 +1,16 @@ +package com.yape.services.transaction.application.command; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Command to create a new transaction. + */ +public record CreateTransactionCommand( + UUID accountExternalIdDebit, + UUID accountExternalIdCredit, + Integer transferTypeId, + Integer transactionStatusId, + String transactionStatusCode, + BigDecimal value +) {} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandler.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandler.java new file mode 100644 index 0000000000..26b3f275dc --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandler.java @@ -0,0 +1,63 @@ +package com.yape.services.transaction.application.command; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Command handler for creating a transaction. + */ +@ApplicationScoped +public class CreateTransactionCommandHandler { + + private static final Logger LOGGER = Logger.getLogger(CreateTransactionCommandHandler.class); + + private final TransactionRepository repository; + private final TransactionCacheService cacheService; + + /** + * Constructor for CreateTransactionCommandHandler. + * + * @param repository the repository for managing transactions + * @param cacheService the cache service for transactions + */ + public CreateTransactionCommandHandler(TransactionRepository repository, + TransactionCacheService cacheService) { + this.repository = repository; + this.cacheService = cacheService; + } + + /** + * Handles the creation of a transaction. + * + * @param command the command containing transaction data + * @return the created transaction + */ + public Transaction handle(CreateTransactionCommand command) { + LOGGER.info("Handling transaction creation command"); + + Transaction tx = buildTransaction(command); + Transaction savedTx = repository.save(tx); + LOGGER.infof("Transaction created with ID: %s", savedTx.getTransactionExternalId()); + + cacheService.saveTransaction(savedTx, command.transactionStatusCode()); + LOGGER.info("Transaction cached successfully"); + + return savedTx; + } + + private Transaction buildTransaction(CreateTransactionCommand command) { + return Transaction.builder() + .transactionExternalId(UUID.randomUUID()) + .accountExternalIdDebit(command.accountExternalIdDebit()) + .accountExternalIdCredit(command.accountExternalIdCredit()) + .transferTypeId(command.transferTypeId()) + .transactionStatusId(command.transactionStatusId()) + .value(command.value()) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/dto/RequestMetaData.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/dto/RequestMetaData.java new file mode 100644 index 0000000000..d03c5e5019 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/dto/RequestMetaData.java @@ -0,0 +1,11 @@ +package com.yape.services.transaction.application.dto; + +/** + * Data transfer object for request metadata. + */ +public record RequestMetaData( + String authorization, + String requestId, + String requestDate +) { +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapper.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapper.java new file mode 100644 index 0000000000..d4b0d62bf6 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapper.java @@ -0,0 +1,101 @@ +package com.yape.services.transaction.application.mapper; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.graphql.model.TransactionType; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.Named; + +/** + * Mapper for converting domain Transaction to GraphQL Transaction model using MapStruct. + */ +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI) +public interface GraphqlTransactionMapper { + + /** + * Maps Transaction domain model to GraphQL Transaction response. + * + * @param tx the domain transaction + * @param transferType the transfer type + * @param txStatus the transaction status + * @return the GraphQL transaction model + */ + @Mapping(target = "transactionExternalId", + source = "tx.transactionExternalId", + qualifiedByName = "uuidToString") + @Mapping(target = "transactionType", + source = "transferType", + qualifiedByName = "toTransactionType") + @Mapping(target = "transactionStatus", + source = "txStatus", + qualifiedByName = "toGraphqlStatus") + @Mapping(target = "value", + source = "tx.value", + qualifiedByName = "bigDecimalToString") + @Mapping(target = "createdAt", + source = "tx.createdAt", + qualifiedByName = "formatDate") + com.yape.services.transaction.graphql.model.Transaction toGraphqlModel( + Transaction tx, + TransferType transferType, + TransactionStatus txStatus + ); + + /** + * Converts UUID to String. + */ + @Named("uuidToString") + default String uuidToString(UUID uuid) { + return uuid != null ? uuid.toString() : null; + } + + /** + * Converts BigDecimal to String. + */ + @Named("bigDecimalToString") + default String bigDecimalToString(java.math.BigDecimal value) { + return value != null ? value.toPlainString() : null; + } + + /** + * Formats LocalDateTime to ISO date string. + */ + @Named("formatDate") + default String formatDate(LocalDateTime dateTime) { + return dateTime != null ? dateTime.format(DateTimeFormatter.ISO_DATE) : null; + } + + /** + * Converts TransferType to GraphQL TransactionType. + */ + @Named("toTransactionType") + default TransactionType toTransactionType(TransferType transferType) { + if (transferType == null) { + return null; + } + return TransactionType.builder() + .setName(transferType.getName()) + .build(); + } + + /** + * Converts domain TransactionStatus to GraphQL TransactionStatus. + */ + @Named("toGraphqlStatus") + default com.yape.services.transaction.graphql.model.TransactionStatus toGraphqlStatus( + TransactionStatus txStatus) { + if (txStatus == null) { + return null; + } + return com.yape.services.transaction.graphql.model.TransactionStatus.builder() + .setName(txStatus.getName()) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransactionMapper.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransactionMapper.java new file mode 100644 index 0000000000..3f6cde9c1d --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransactionMapper.java @@ -0,0 +1,64 @@ +package com.yape.services.transaction.application.mapper; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.events.TransactionCreatedPayload; +import jakarta.enterprise.context.ApplicationScoped; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +/** + * Mapper for transaction Avro events. + * Note: This mapper handles Avro-specific builders which are not suitable for MapStruct. + */ +@ApplicationScoped +public class TransactionMapper { + + private static final String EVENT_TYPE = "TRANSACTION_CREATED"; + private static final String EVENT_SOURCE = "ms-transaction"; + private static final String EVENT_VERSION = "1.0.0"; + + /** + * Maps Transaction and RequestMetaData to TransactionCreatedEvent. + * + * @param tx the transaction + * @param txStatus the transaction status + * @param metaData the request metadata + * @return the transaction created event + */ + public TransactionCreatedEvent toTransactionCreatedEvent(Transaction tx, + TransactionStatus txStatus, + RequestMetaData metaData) { + String timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.now()); + + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType(EVENT_TYPE) + .setEventTimestamp(timestamp) + .setSource(EVENT_SOURCE) + .setVersion(EVENT_VERSION) + .setRequestId(metaData != null ? metaData.requestId() : null) + .build(); + + TransactionCreatedPayload payload = TransactionCreatedPayload.newBuilder() + .setTransactionExternalId(tx.getTransactionExternalId().toString()) + .setAccountExternalIdDebit(tx.getAccountExternalIdDebit().toString()) + .setAccountExternalIdCredit(tx.getAccountExternalIdCredit().toString()) + .setTransferTypeId(tx.getTransferTypeId()) + .setValue(tx.getValue().toPlainString()) + .setStatus(com.yape.services.transaction.events.enums.TransactionStatus + .valueOf(txStatus.getCode())) + .setCreatedAt(timestamp) + .build(); + + return TransactionCreatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransferTypeMapper.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransferTypeMapper.java new file mode 100644 index 0000000000..5932149b45 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransferTypeMapper.java @@ -0,0 +1,26 @@ +package com.yape.services.transaction.application.mapper; + +import com.yape.services.transaction.domain.model.TransferType; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; + +/** + * Mapper for TransferType entities using MapStruct. + */ +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI) +public interface TransferTypeMapper { + + /** + * Converts a domain TransferType model to a GraphQL TransferType model. + * + * @param transferType the domain TransferType model + * @return the GraphQL TransferType model + */ + @Mapping(target = "transferTypeId", + expression = "java(String.valueOf(transferType.getTransferTypeId()))") + @Mapping(target = "name", source = "name") + com.yape.services.transaction.graphql.model.TransferType toGraphqlModel( + TransferType transferType); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionQueryHandler.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionQueryHandler.java new file mode 100644 index 0000000000..9be45231db --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionQueryHandler.java @@ -0,0 +1,79 @@ +package com.yape.services.transaction.application.query; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Optional; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Query handler for transaction related operations. + */ +@ApplicationScoped +public class TransactionQueryHandler { + + private static final Logger LOGGER = Logger.getLogger(TransactionQueryHandler.class); + + private final TransactionRepository transactionRepository; + private final TransactionStatusRepository transactionStatusRepository; + private final TransactionCacheService cacheService; + + /** + * Constructor for TransactionQueryHandler. + * + * @param transactionRepository the repository for transaction data + * @param transactionStatusRepository the repository for transaction status data + * @param cacheService the cache service for transactions + */ + public TransactionQueryHandler(TransactionRepository transactionRepository, + TransactionStatusRepository transactionStatusRepository, + TransactionCacheService cacheService) { + this.transactionRepository = transactionRepository; + this.transactionStatusRepository = transactionStatusRepository; + this.cacheService = cacheService; + } + + /** + * Retrieves a transaction by its external ID. + * Implements cache-aside pattern: check cache first, if miss read from DB and populate cache. + * + * @param externalId the external ID of the transaction + * @return an Optional containing the transaction, or empty if not found + */ + public Optional getTransactionByExternalId(UUID externalId) { + LOGGER.infof("Fetching transaction with external ID: %s", externalId); + + Optional cachedTransaction = cacheService.getTransactionByExternalId(externalId); + if (cachedTransaction.isPresent()) { + return cachedTransaction; + } + + LOGGER.info("Cache miss - reading from database"); + Optional transaction = transactionRepository.findByExternalId(externalId); + + transaction.ifPresent(this::cacheTransactionWithStatus); + + return transaction; + } + + /** + * Caches a transaction with the appropriate TTL based on its status. + * + * @param transaction the transaction to cache + */ + private void cacheTransactionWithStatus(Transaction transaction) { + transactionStatusRepository.findById(transaction.getTransactionStatusId()) + .ifPresentOrElse( + status -> { + cacheService.saveTransaction(transaction, status.getCode()); + LOGGER.infof("Transaction cached with status: %s", status.getCode()); + }, + () -> LOGGER.warnf("Could not cache transaction - status not found for ID: %d", + transaction.getTransactionStatusId()) + ); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandler.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandler.java new file mode 100644 index 0000000000..9297531de0 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandler.java @@ -0,0 +1,50 @@ +package com.yape.services.transaction.application.query; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Optional; +import org.jboss.logging.Logger; + +/** + * Query handler for transaction status related operations. + */ +@ApplicationScoped +public class TransactionStatusQueryHandler { + + private static final Logger LOGGER = Logger.getLogger(TransactionStatusQueryHandler.class); + + private final TransactionStatusRepository transactionStatusRepository; + + /** + * Constructor for TransactionStatusQueryHandler. + * + * @param transactionStatusRepository the repository for transaction status data + */ + public TransactionStatusQueryHandler(TransactionStatusRepository transactionStatusRepository) { + this.transactionStatusRepository = transactionStatusRepository; + } + + /** + * Retrieves a transaction status by its code. + * + * @param code the code of the transaction status + * @return an Optional containing the transaction status, or empty if not found + */ + public Optional getTransactionStatusByCode(String code) { + LOGGER.infof("Fetching transaction status with code: %s", code); + return transactionStatusRepository.findByCode(code); + } + + /** + * Retrieves a transaction status by its ID. + * + * @param id the ID of the transaction status + * @return an Optional containing the transaction status, or empty if not found + */ + public Optional getTransactionStatusById(Integer id) { + LOGGER.infof("Fetching transaction status with ID: %d", id); + return transactionStatusRepository.findById(id); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransferTypeQueryHandler.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransferTypeQueryHandler.java new file mode 100644 index 0000000000..6a9d978608 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransferTypeQueryHandler.java @@ -0,0 +1,76 @@ +package com.yape.services.transaction.application.query; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.repository.TransferTypeRepository; +import com.yape.services.transaction.domain.service.TransferTypeCacheService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.Optional; +import org.jboss.logging.Logger; + +/** + * Query handler for transfer type related operations. + * Implements cache-aside pattern for improved performance. + */ +@ApplicationScoped +public class TransferTypeQueryHandler { + + private static final Logger LOGGER = Logger.getLogger(TransferTypeQueryHandler.class); + + private final TransferTypeRepository transferTypeRepository; + private final TransferTypeCacheService cacheService; + + /** + * Constructor for TransferTypeQueryHandler. + * + * @param transferTypeRepository the repository for transfer type data + * @param cacheService the cache service for transfer types + */ + @Inject + public TransferTypeQueryHandler(TransferTypeRepository transferTypeRepository, + TransferTypeCacheService cacheService) { + this.transferTypeRepository = transferTypeRepository; + this.cacheService = cacheService; + } + + /** + * Retrieves a transfer type by its ID using cache-aside pattern. + * First checks cache, if miss reads from DB and populates cache. + * + * @param transferTypeId the ID of the transfer type + * @return an Optional containing the transfer type, or empty if not found + */ + public Optional getTransferTypeById(Integer transferTypeId) { + LOGGER.infof("Fetching transfer type with ID: %s", transferTypeId); + + Optional cachedTransferType = cacheService.findById(transferTypeId); + if (cachedTransferType.isPresent()) { + return cachedTransferType; + } + + LOGGER.info("Reading from database and caching"); + Optional transferType = transferTypeRepository.findById(transferTypeId); + transferType.ifPresent(cacheService::save); + return transferType; + } + + /** + * Retrieves all transfer types using cache-aside pattern. + * First checks cache, if miss reads from DB and populates cache. + * + * @return list of all transfer types + */ + public List getAll() { + LOGGER.info("Fetching all transfer types"); + + return cacheService.findAll() + .orElseGet(() -> { + LOGGER.info("Reading all from database and caching"); + List transferTypes = transferTypeRepository.findAll(); + cacheService.saveAll(transferTypes); + return transferTypes; + }); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCase.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCase.java new file mode 100644 index 0000000000..c036d6a239 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCase.java @@ -0,0 +1,174 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.shared.exception.BusinessException; +import com.yape.services.shared.exception.ErrorCode; +import com.yape.services.shared.exception.ResourceNotFoundException; +import com.yape.services.shared.exception.ValidationException; +import com.yape.services.shared.util.Constants; +import com.yape.services.transaction.application.command.CreateTransactionCommand; +import com.yape.services.transaction.application.command.CreateTransactionCommandHandler; +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.application.mapper.GraphqlTransactionMapper; +import com.yape.services.transaction.application.mapper.TransactionMapper; +import com.yape.services.transaction.application.query.TransactionStatusQueryHandler; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.service.TransactionEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.graphql.model.CreateTransaction; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Use case for creating a transaction. + */ +@ApplicationScoped +public class CreateTransactionUseCase { + + private static final BigDecimal MIN_AMOUNT = BigDecimal.valueOf(0.00); + private static final Logger LOGGER = Logger.getLogger(CreateTransactionUseCase.class); + + private final TransactionEventPublisher transactionEventPublisher; + private final TransactionMapper transactionMapper; + private final GraphqlTransactionMapper graphqlTransactionMapper; + private final CreateTransactionCommandHandler createTransactionCommandHandler; + private final TransferTypeQueryHandler transferTypeQueryHandler; + private final TransactionStatusQueryHandler transactionStatusQueryHandler; + + /** + * Constructor for CreateTransactionUseCase. + * + * @param createTransactionCommandHandler the command handler for creating transactions + * @param transactionEventPublisher the event publisher for transaction events + * @param transactionMapper the mapper for transaction events + * @param graphqlTransactionMapper the mapper for GraphQL responses + * @param transferTypeQueryHandler the query handler for transfer types + * @param transactionStatusQueryHandler the query handler for transaction statuses + */ + public CreateTransactionUseCase(CreateTransactionCommandHandler createTransactionCommandHandler, + TransactionEventPublisher transactionEventPublisher, + TransactionMapper transactionMapper, + GraphqlTransactionMapper graphqlTransactionMapper, + TransferTypeQueryHandler transferTypeQueryHandler, + TransactionStatusQueryHandler transactionStatusQueryHandler) { + this.createTransactionCommandHandler = createTransactionCommandHandler; + this.transactionEventPublisher = transactionEventPublisher; + this.transactionMapper = transactionMapper; + this.graphqlTransactionMapper = graphqlTransactionMapper; + this.transferTypeQueryHandler = transferTypeQueryHandler; + this.transactionStatusQueryHandler = transactionStatusQueryHandler; + } + + /** + * Executes the use case to create a transaction. + * + * @param input the input data for creating a transaction + * @param metaData the request metadata + * @return the created transaction details + */ + public com.yape.services.transaction.graphql.model.Transaction execute( + @NotNull CreateTransaction input, + RequestMetaData metaData + ) { + BigDecimal value = parseAndValidateAmount(input.getValue()); + TransferType transferType = getTransferType(input.getTransferTypeId()); + TransactionStatus status = getPendingStatus(); + + CreateTransactionCommand command = buildCommand(input, transferType, status, value); + Transaction savedTransaction = createTransactionCommandHandler.handle(command); + + publishTransactionCreatedEvent(savedTransaction, status, metaData); + + return graphqlTransactionMapper.toGraphqlModel(savedTransaction, transferType, status); + } + + private BigDecimal parseAndValidateAmount(String value) { + if (value == null || value.isBlank()) { + LOGGER.error("Transaction value is null or empty"); + throw new ValidationException(ErrorCode.VALIDATION_ERROR, + "value", "Transaction value is required"); + } + + BigDecimal amount; + try { + amount = new BigDecimal(value); + } catch (NumberFormatException e) { + LOGGER.errorf("Invalid transaction value format: %s", value); + throw new ValidationException(ErrorCode.INVALID_FORMAT, + "value", "Invalid transaction value format"); + } + + if (amount.compareTo(MIN_AMOUNT) < 0) { + LOGGER.errorf("Transaction value %s is below minimum %s", amount, MIN_AMOUNT); + throw new ValidationException(ErrorCode.AMOUNT_BELOW_MINIMUM, + "value", "Transaction value is below the minimum allowed"); + } + return amount; + } + + private TransferType getTransferType(int transferTypeId) { + return transferTypeQueryHandler.getTransferTypeById(transferTypeId) + .orElseThrow(() -> { + LOGGER.errorf("Invalid transfer type ID: %d", transferTypeId); + return new ResourceNotFoundException(ErrorCode.TRANSFER_TYPE_NOT_FOUND, + "TransferType", String.valueOf(transferTypeId)); + }); + } + + private TransactionStatus getPendingStatus() { + return transactionStatusQueryHandler + .getTransactionStatusByCode(Constants.TRANSACTION_STATUS_PENDING) + .orElseThrow(() -> { + LOGGER.error("PENDING transaction status not found in database"); + return new BusinessException(ErrorCode.CONFIGURATION_ERROR, + "PENDING status configuration missing"); + }); + } + + private CreateTransactionCommand buildCommand(CreateTransaction input, + TransferType transferType, + TransactionStatus status, + BigDecimal value) { + UUID debitAccountId = parseUuid(input.getAccountExternalIdDebit(), "accountExternalIdDebit"); + UUID creditAccountId = parseUuid(input.getAccountExternalIdCredit(), "accountExternalIdCredit"); + + return new CreateTransactionCommand( + debitAccountId, + creditAccountId, + transferType.getTransferTypeId(), + status.getTransactionStatusId(), + status.getCode(), + value + ); + } + + private UUID parseUuid(String value, String fieldName) { + if (value == null || value.isBlank()) { + LOGGER.errorf("Field %s is null or empty", fieldName); + throw new ValidationException(ErrorCode.VALIDATION_ERROR, + fieldName, fieldName + " is required"); + } + + try { + return UUID.fromString(value); + } catch (IllegalArgumentException e) { + LOGGER.errorf("Invalid UUID format for %s: %s", fieldName, value); + throw new ValidationException(ErrorCode.INVALID_FORMAT, + fieldName, "Invalid UUID format for " + fieldName); + } + } + + private void publishTransactionCreatedEvent(Transaction transaction, + TransactionStatus status, + RequestMetaData metaData) { + TransactionCreatedEvent event = transactionMapper + .toTransactionCreatedEvent(transaction, status, metaData); + transactionEventPublisher.publishTransactionCreated(event); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransactionUseCase.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransactionUseCase.java new file mode 100644 index 0000000000..2425bd9443 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransactionUseCase.java @@ -0,0 +1,91 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.shared.exception.ErrorCode; +import com.yape.services.shared.exception.ResourceNotFoundException; +import com.yape.services.transaction.application.mapper.GraphqlTransactionMapper; +import com.yape.services.transaction.application.query.TransactionQueryHandler; +import com.yape.services.transaction.application.query.TransactionStatusQueryHandler; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.graphql.model.Transaction; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Use case for retrieving a transaction. + */ +@ApplicationScoped +public class GetTransactionUseCase { + + private static final Logger LOGGER = Logger.getLogger(GetTransactionUseCase.class); + + private final TransactionQueryHandler transactionQueryHandler; + private final TransferTypeQueryHandler transferTypeQueryHandler; + private final TransactionStatusQueryHandler transactionStatusQueryHandler; + private final GraphqlTransactionMapper mapper; + + /** + * Constructor for GetTransactionUseCase. + * + * @param transactionQueryHandler the handler for querying transactions + * @param transferTypeQueryHandler the handler for querying transfer types + * @param transactionStatusQueryHandler the handler for querying transaction statuses + * @param mapper the mapper for converting transaction data + * + */ + @Inject + public GetTransactionUseCase(TransactionQueryHandler transactionQueryHandler, + TransferTypeQueryHandler transferTypeQueryHandler, + TransactionStatusQueryHandler transactionStatusQueryHandler, + GraphqlTransactionMapper mapper) { + this.transactionQueryHandler = transactionQueryHandler; + this.transferTypeQueryHandler = transferTypeQueryHandler; + this.transactionStatusQueryHandler = transactionStatusQueryHandler; + this.mapper = mapper; + } + + /** + * Executes the use case to retrieve a transaction by its external ID. + * + * @param transactionExternalId the external ID of the transaction + * @return the retrieved transaction + */ + public Transaction execute(String transactionExternalId) { + LOGGER.infof("Executing GetTransactionUseCase for transaction ID: %s", transactionExternalId); + UUID externalId = UUID.fromString(transactionExternalId); + + var transaction = transactionQueryHandler.getTransactionByExternalId(externalId) + .orElseThrow(() -> { + LOGGER.errorf("Transaction not found with external ID: %s", transactionExternalId); + return new ResourceNotFoundException(ErrorCode.TRANSACTION_NOT_FOUND, + "Transaction", transactionExternalId); + }); + + var transferType = getTransferType(transaction.getTransferTypeId()); + var transactionStatus = getTransactionStatus(transaction.getTransactionStatusId()); + + return mapper.toGraphqlModel(transaction, transferType, transactionStatus); + } + + private TransferType getTransferType(int transferTypeId) { + return transferTypeQueryHandler.getTransferTypeById(transferTypeId) + .orElseThrow(() -> { + LOGGER.errorf("Invalid transfer type ID: %d", transferTypeId); + return new ResourceNotFoundException(ErrorCode.TRANSFER_TYPE_NOT_FOUND, + "TransferType", String.valueOf(transferTypeId)); + }); + } + + private TransactionStatus getTransactionStatus(int transactionStatusId) { + return transactionStatusQueryHandler.getTransactionStatusById(transactionStatusId) + .orElseThrow(() -> { + LOGGER.errorf("Invalid transaction status ID: %d", transactionStatusId); + return new ResourceNotFoundException(ErrorCode.TRANSACTION_STATUS_NOT_FOUND, + "TransactionStatus", String.valueOf(transactionStatusId)); + }); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransferTypesUseCase.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransferTypesUseCase.java new file mode 100644 index 0000000000..d641479208 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransferTypesUseCase.java @@ -0,0 +1,47 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.transaction.application.mapper.TransferTypeMapper; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.TransferType; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import org.jboss.logging.Logger; + +/** + * Use case for retrieving transfer types. + */ +@ApplicationScoped +public class GetTransferTypesUseCase { + + private static final Logger LOGGER = Logger.getLogger(GetTransferTypesUseCase.class); + + private final TransferTypeQueryHandler transferTypeQueryHandler; + private final TransferTypeMapper transferTypeMapper; + + /** + * Constructor for GetTransferTypesUseCase. + * + * @param transferTypeQueryHandler the query handler for transfer types + * @param transferTypeMapper the mapper for transfer types + */ + public GetTransferTypesUseCase(TransferTypeQueryHandler transferTypeQueryHandler, + TransferTypeMapper transferTypeMapper) { + this.transferTypeQueryHandler = transferTypeQueryHandler; + this.transferTypeMapper = transferTypeMapper; + } + + /** + * Executes the use case to retrieve transfer types. + * + * @return a list of transfer types + */ + public List execute() { + LOGGER.info("Executing GetTransferTypesUseCase"); + List transferTypes = transferTypeQueryHandler.getAll(); + + return transferTypes.stream() + .map(transferTypeMapper::toGraphqlModel) + .toList(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCase.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCase.java new file mode 100644 index 0000000000..7fd3fdb72b --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCase.java @@ -0,0 +1,86 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Use case for updating a transaction status based on anti-fraud validation results. + * Updates both PostgreSQL and Redis cache. + */ +@ApplicationScoped +public class UpdateTransactionStatusUseCase { + + private static final Logger LOGGER = Logger.getLogger(UpdateTransactionStatusUseCase.class); + + private final TransactionRepository transactionRepository; + private final TransactionStatusRepository transactionStatusRepository; + private final TransactionCacheService transactionCacheService; + + /** + * Constructor for UpdateTransactionStatusUseCase. + * + * @param transactionRepository repository for transaction persistence + * @param transactionStatusRepository repository for transaction status lookup + * @param transactionCacheService service for cache operations + */ + @Inject + public UpdateTransactionStatusUseCase(TransactionRepository transactionRepository, + TransactionStatusRepository transactionStatusRepository, + TransactionCacheService transactionCacheService) { + this.transactionRepository = transactionRepository; + this.transactionStatusRepository = transactionStatusRepository; + this.transactionCacheService = transactionCacheService; + } + + /** + * Executes the transaction status update. + * + * @param event the transaction status updated event from anti-fraud service + */ + @Transactional + public void execute(TransactionStatusUpdatedEvent event) { + TransactionStatusUpdatedPayload payload = event.getPayload(); + String transactionExternalIdStr = payload.getTransactionExternalId(); + UUID transactionExternalId = UUID.fromString(transactionExternalIdStr); + String newStatusCode = payload.getNewStatus().name(); + + LOGGER.infof("Processing status update for transaction: %s, new status: %s", + transactionExternalIdStr, newStatusCode); + + TransactionStatus newStatus = transactionStatusRepository.findByCode(newStatusCode) + .orElseThrow(() -> { + LOGGER.errorf("Transaction status not found for code: %s", newStatusCode); + return new IllegalStateException("Transaction status not found: " + newStatusCode); + }); + + var updatedRows = transactionRepository.updateStatus(transactionExternalId, + newStatus.getTransactionStatusId()); + + if (updatedRows == 0) { + LOGGER.warnf("No transaction found to update with external ID: %s", transactionExternalIdStr); + return; + } + + LOGGER.infof("Transaction %s status updated in database to: %s", + transactionExternalIdStr, newStatusCode); + + transactionCacheService.updateTransactionStatus( + transactionExternalId, + newStatus.getTransactionStatusId(), + newStatusCode + ); + + LOGGER.infof("Transaction %s status updated in cache to: %s", + transactionExternalIdStr, newStatusCode); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/Transaction.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/Transaction.java new file mode 100644 index 0000000000..c68a964d33 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/Transaction.java @@ -0,0 +1,29 @@ +package com.yape.services.transaction.domain.model; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Domain model representing a transaction. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Transaction { + private Long transactionId; + private UUID transactionExternalId; + private UUID accountExternalIdDebit; + private UUID accountExternalIdCredit; + private Integer transferTypeId; + private Integer transactionStatusId; + private BigDecimal value; + private LocalDateTime createdAt; +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransactionStatus.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransactionStatus.java new file mode 100644 index 0000000000..0de7707ed6 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransactionStatus.java @@ -0,0 +1,21 @@ +package com.yape.services.transaction.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Domain model representing a transaction status. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionStatus { + private Integer transactionStatusId; + private String code; + private String name; +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransferType.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransferType.java new file mode 100644 index 0000000000..956ed74b40 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransferType.java @@ -0,0 +1,21 @@ +package com.yape.services.transaction.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Domain model representing a transfer type. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransferType { + private Integer transferTypeId; + private String code; + private String name; +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionRepository.java new file mode 100644 index 0000000000..d46e19b2fb --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionRepository.java @@ -0,0 +1,37 @@ +package com.yape.services.transaction.domain.repository; + +import com.yape.services.transaction.domain.model.Transaction; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository interface for managing transactions. + */ +public interface TransactionRepository { + + /** + * Saves a transaction to the repository. + * + * @param tx the transaction to save + * @return the saved transaction + */ + Transaction save(Transaction tx); + + /** + * Finds a transaction by its external ID. + * + * @param externalId the external ID of the transaction + * @return an Optional containing the found transaction, or empty if not found + */ + Optional findByExternalId(UUID externalId); + + /** + * Updates the status of a transaction. + * + * @param externalId the external ID of the transaction + * @param newStatusId the new status ID to set + * @return the number of updated records + */ + int updateStatus(UUID externalId, Integer newStatusId); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionStatusRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionStatusRepository.java new file mode 100644 index 0000000000..29cfc36442 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionStatusRepository.java @@ -0,0 +1,27 @@ +package com.yape.services.transaction.domain.repository; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import java.util.Optional; + +/** + * Repository interface for transaction status data operations. + */ +public interface TransactionStatusRepository { + + /** + * Finds a transaction status by its code. + * + * @param code the code of the transaction status + * @return an Optional containing the transaction status, or empty if not found + */ + Optional findByCode(String code); + + /** + * Finds a transaction status by its ID. + * + * @param id the ID of the transaction status + * @return an Optional containing the transaction status, or empty if not found + */ + Optional findById(Integer id); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransferTypeRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransferTypeRepository.java new file mode 100644 index 0000000000..f8d00da35f --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransferTypeRepository.java @@ -0,0 +1,27 @@ +package com.yape.services.transaction.domain.repository; + +import com.yape.services.transaction.domain.model.TransferType; +import java.util.List; +import java.util.Optional; + +/** + * Repository interface for transfer type data operations. + */ +public interface TransferTypeRepository { + + /** + * Finds a transfer type by its ID. + * + * @param transferTypeId the ID of the transfer type + * @return an Optional containing the transfer type, or empty if not found + */ + Optional findById(Integer transferTypeId); + + /** + * Retrieves all transfer types. + * + * @return a list of all transfer types + */ + List findAll(); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionCacheService.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionCacheService.java new file mode 100644 index 0000000000..c18f2e1502 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionCacheService.java @@ -0,0 +1,39 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.transaction.domain.model.Transaction; +import java.util.Optional; +import java.util.UUID; + +/** + * Service interface for managing transaction cache operations. + */ +public interface TransactionCacheService { + + /** + * Save a transaction to the cache. + * + * @param transaction The transaction to save. + * @param statusCode The status code of the transaction. + */ + void saveTransaction(Transaction transaction, String statusCode); + + /** + * Retrieve a transaction from the cache by its ID. + * + * @param externalId The ID of the transaction to retrieve. + * @return An Optional containing the Transaction if found, or empty if not found. + */ + Optional getTransactionByExternalId(UUID externalId); + + /** + * Update the status of a transaction in the cache. + * + * @param externalId The external ID of the transaction to update. + * @param newStatusId The new status ID to set for the transaction. + * @param newStatusCode The new status to set for the transaction. + */ + void updateTransactionStatus(UUID externalId, + Integer newStatusId, + String newStatusCode); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionEventPublisher.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionEventPublisher.java new file mode 100644 index 0000000000..b45277332d --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionEventPublisher.java @@ -0,0 +1,17 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.transaction.events.TransactionCreatedEvent; + +/** + * Interface for publishing transaction-related events. + */ +public interface TransactionEventPublisher { + + /** + * Publishes a TransactionCreatedEvent. + * + * @param event the event to publish + */ + void publishTransactionCreated(TransactionCreatedEvent event); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransferTypeCacheService.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransferTypeCacheService.java new file mode 100644 index 0000000000..411c17b5f9 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransferTypeCacheService.java @@ -0,0 +1,41 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.transaction.domain.model.TransferType; +import java.util.List; +import java.util.Optional; + +/** + * Service interface for managing transfer type cache operations. + */ +public interface TransferTypeCacheService { + + /** + * Save a transfer type to the cache. + * + * @param transferType the transfer type to save + */ + void save(TransferType transferType); + + /** + * Save all transfer types to the cache. + * + * @param transferTypes the list of transfer types to save + */ + void saveAll(List transferTypes); + + /** + * Retrieve a transfer type from the cache by its ID. + * + * @param transferTypeId the ID of the transfer type + * @return an Optional containing the TransferType if found + */ + Optional findById(Integer transferTypeId); + + /** + * Retrieve all transfer types from the cache. + * + * @return an Optional containing the list if cache is populated + */ + Optional> findAll(); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImpl.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImpl.java new file mode 100644 index 0000000000..382726fa96 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImpl.java @@ -0,0 +1,110 @@ +package com.yape.services.transaction.infrastructure.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.yape.services.shared.util.CacheKeyUtils; +import com.yape.services.shared.util.Constants; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import com.yape.services.transaction.infrastructure.config.TransactionCacheConfig; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.jboss.logging.Logger; +import org.redisson.api.RMapCache; +import org.redisson.api.RedissonClient; +import org.redisson.codec.TypedJsonJacksonCodec; + +/** + * Redis implementation of TransactionCacheService using Redisson RMapCache. + */ +@ApplicationScoped +public class TransactionCacheServiceImpl implements TransactionCacheService { + + private static final Logger LOGGER = Logger.getLogger(TransactionCacheServiceImpl.class); + + private final RMapCache transactionCache; + private final TransactionCacheConfig cacheConfig; + + /** + * Constructor for TransactionCacheServiceImpl. + * + * @param redissonClient the Redisson client + * @param cacheConfig the cache configuration + */ + @Inject + public TransactionCacheServiceImpl(RedissonClient redissonClient, + TransactionCacheConfig cacheConfig) { + ObjectMapper mapper = createObjectMapper(); + TypedJsonJacksonCodec codec = + new TypedJsonJacksonCodec(String.class, Transaction.class, mapper); + this.transactionCache = redissonClient.getMapCache(cacheConfig.mapName(), codec); + this.cacheConfig = cacheConfig; + } + + private static ObjectMapper createObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } + + @Override + public void saveTransaction(Transaction transaction, String statusCode) { + String key = buildKey(transaction.getTransactionExternalId()); + long ttl = getTtlForStatus(statusCode); + + transactionCache.put(key, transaction, ttl, TimeUnit.SECONDS); + LOGGER.infof("Transaction cached with key: %s, TTL: %d seconds", key, ttl); + } + + @Override + public Optional getTransactionByExternalId(UUID externalId) { + String key = buildKey(externalId); + Transaction transaction = transactionCache.get(key); + + if (transaction != null) { + LOGGER.infof("Transaction found in cache with key: %s", key); + return Optional.of(transaction); + } + + LOGGER.infof("Transaction not found in cache with key: %s", key); + return Optional.empty(); + } + + @Override + public void updateTransactionStatus(UUID externalId, + Integer newStatusId, + String newStatusCode) { + String key = buildKey(externalId); + Transaction transaction = transactionCache.get(key); + + if (transaction == null) { + LOGGER.warnf("Transaction not found in cache for status update: %s", key); + return; + } + + transaction.setTransactionStatusId(newStatusId); + long ttl = getTtlForStatus(newStatusCode); + + transactionCache.put(key, transaction, ttl, TimeUnit.SECONDS); + LOGGER.infof("Transaction status updated to %s with new TTL: %d seconds", newStatusCode, ttl); + } + + private String buildKey(UUID transactionExternalId) { + return CacheKeyUtils.buildKey(cacheConfig.prefix(), transactionExternalId.toString()); + } + + private long getTtlForStatus(String status) { + return switch (status) { + case Constants.TRANSACTION_STATUS_PENDING -> cacheConfig.ttl().pending(); + case Constants.TRANSACTION_STATUS_APPROVED -> cacheConfig.ttl().approved(); + case Constants.TRANSACTION_STATUS_REJECTED -> cacheConfig.ttl().rejected(); + default -> throw new IllegalStateException("Unexpected value: " + status); + }; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImpl.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImpl.java new file mode 100644 index 0000000000..3c9bba0d4d --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImpl.java @@ -0,0 +1,94 @@ +package com.yape.services.transaction.infrastructure.cache; + +import com.yape.services.shared.util.CacheKeyUtils; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.service.TransferTypeCacheService; +import com.yape.services.transaction.infrastructure.config.TransferTypeCacheConfig; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.jboss.logging.Logger; +import org.redisson.api.RMapCache; +import org.redisson.api.RedissonClient; +import org.redisson.codec.TypedJsonJacksonCodec; + +/** + * Redis implementation of TransferTypeCacheService using Redisson RMapCache. + * Uses cache-aside pattern for transfer type data. + */ +@ApplicationScoped +public class TransferTypeCacheServiceImpl implements TransferTypeCacheService { + + private static final Logger LOGGER = Logger.getLogger(TransferTypeCacheServiceImpl.class); + + private final RMapCache transferTypeCache; + private final TransferTypeCacheConfig cacheConfig; + + /** + * Constructor for TransferTypeCacheServiceImpl. + * + * @param redissonClient the Redisson client + * @param cacheConfig the cache configuration + */ + @Inject + public TransferTypeCacheServiceImpl(RedissonClient redissonClient, + TransferTypeCacheConfig cacheConfig) { + TypedJsonJacksonCodec codec = new TypedJsonJacksonCodec(String.class, TransferType.class); + this.transferTypeCache = redissonClient.getMapCache(cacheConfig.mapName(), codec); + this.cacheConfig = cacheConfig; + } + + @Override + public void save(TransferType transferType) { + String key = buildKey(transferType.getTransferTypeId()); + transferTypeCache.put(key, transferType, cacheConfig.ttl(), TimeUnit.SECONDS); + LOGGER.infof("TransferType cached with key: %s, TTL: %d seconds", key, cacheConfig.ttl()); + } + + @Override + public void saveAll(List transferTypes) { + transferTypes.forEach(this::save); + LOGGER.infof("Cached %d transfer types", transferTypes.size()); + } + + @Override + public Optional findById(Integer transferTypeId) { + String key = buildKey(transferTypeId); + TransferType transferType = transferTypeCache.get(key); + + if (transferType != null) { + LOGGER.infof("Cache HIT for transfer type key: %s", key); + return Optional.of(transferType); + } + + LOGGER.infof("Cache MISS for transfer type key: %s", key); + return Optional.empty(); + } + + @Override + public Optional> findAll() { + if (transferTypeCache.isEmpty()) { + LOGGER.info("Cache MISS for all transfer types"); + return Optional.empty(); + } + + List transferTypes = transferTypeCache.values().stream() + .collect(Collectors.toMap( + TransferType::getTransferTypeId, + t -> t, + (existing, replacement) -> existing)) + .values() + .stream() + .toList(); + LOGGER.infof("Cache HIT for all transfer types, count: %d", transferTypes.size()); + return Optional.of(transferTypes); + } + + private String buildKey(Integer transferTypeId) { + return CacheKeyUtils.buildKey(cacheConfig.prefix(), String.valueOf(transferTypeId)); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransactionCacheConfig.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransactionCacheConfig.java new file mode 100644 index 0000000000..e013c844ae --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransactionCacheConfig.java @@ -0,0 +1,65 @@ +package com.yape.services.transaction.infrastructure.config; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithName; + +/** + * Configuration for transaction cache settings. + */ +@ConfigMapping(prefix = "application.cache.transaction") +public interface TransactionCacheConfig { + + /** + * Gets the map name for the transaction cache. + * + * @return the name of the cache map + */ + @WithName("map-name") + String mapName(); + + /** + * Gets the cache key prefix. + * + * @return the prefix for cache keys + */ + String prefix(); + + /** + * Gets the TTL configuration. + * + * @return the TTL settings + */ + Ttl ttl(); + + /** + * TTL configuration for different transaction statuses. + */ + interface Ttl { + + /** + * TTL in seconds for pending transactions. + * + * @return TTL in seconds + */ + @WithName("pending") + long pending(); + + /** + * TTL in seconds for approved transactions. + * + * @return TTL in seconds + */ + @WithName("approved") + long approved(); + + /** + * TTL in seconds for rejected transactions. + * + * @return TTL in seconds + */ + @WithName("rejected") + long rejected(); + + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransferTypeCacheConfig.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransferTypeCacheConfig.java new file mode 100644 index 0000000000..df94daaffe --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransferTypeCacheConfig.java @@ -0,0 +1,34 @@ +package com.yape.services.transaction.infrastructure.config; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithName; + +/** + * Configuration for transfer type cache settings. + */ +@ConfigMapping(prefix = "application.cache.transfer-type") +public interface TransferTypeCacheConfig { + + /** + * Gets the RMapCache name. + * + * @return the map name + */ + @WithName("map-name") + String mapName(); + + /** + * Gets the cache key prefix. + * + * @return the prefix for cache keys + */ + String prefix(); + + /** + * Gets the TTL in seconds. + * + * @return TTL in seconds + */ + long ttl(); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisher.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisher.java new file mode 100644 index 0000000000..42a394098f --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisher.java @@ -0,0 +1,52 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import com.yape.services.transaction.domain.service.TransactionEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.jboss.logging.Logger; + +/** + * Kafka implementation of TransactionEventPublisher. + * Publishes transaction events to Kafka topics using MicroProfile Reactive Messaging. + */ +@ApplicationScoped +public class KafkaTransactionEventPublisher implements TransactionEventPublisher { + + private static final Logger LOGGER = Logger.getLogger(KafkaTransactionEventPublisher.class); + + private final Emitter> transactionCreatedEmitter; + + /** + * Constructor for KafkaTransactionEventPublisher. + * + * @param transactionCreatedEmitter the emitter for transaction created events + */ + @Inject + public KafkaTransactionEventPublisher( + @Channel("transaction-producer") + Emitter> transactionCreatedEmitter + ) { + this.transactionCreatedEmitter = transactionCreatedEmitter; + } + + @Override + public void publishTransactionCreated(TransactionCreatedEvent event) { + String key = event.getPayload().getTransactionExternalId(); + + LOGGER.infof("Publishing TransactionCreatedEvent with key: %s", key); + + transactionCreatedEmitter.send(Record.of(key, event)) + .whenComplete((result, error) -> { + if (error != null) { + LOGGER.errorf(error, "Failed to publish TransactionCreatedEvent with key: %s", key); + } else { + LOGGER.infof("Successfully published TransactionCreatedEvent with key: %s", key); + } + }); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumer.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumer.java new file mode 100644 index 0000000000..701967d71f --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumer.java @@ -0,0 +1,54 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import com.yape.services.transaction.application.usecase.UpdateTransactionStatusUseCase; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.jboss.logging.Logger; + +/** + * Kafka consumer for transaction status updated events. + * Listens to the 'transaction.status' topic and delegates to the update use case. + */ +@ApplicationScoped +public class KafkaTransactionStatusConsumer { + + private static final Logger LOGGER = Logger.getLogger(KafkaTransactionStatusConsumer.class); + + private final UpdateTransactionStatusUseCase updateTransactionStatusUseCase; + + /** + * Constructor for KafkaTransactionStatusConsumer. + * + * @param updateTransactionStatusUseCase the use case for updating transaction status + */ + @Inject + public KafkaTransactionStatusConsumer( + UpdateTransactionStatusUseCase updateTransactionStatusUseCase) { + this.updateTransactionStatusUseCase = updateTransactionStatusUseCase; + } + + /** + * Consumes transaction status updated events from Kafka. + * + * @param kafkaRecord the Kafka record containing the transaction status updated event + */ + @Incoming("transaction-status-consumer") + public void consume(Record kafkaRecord) { + String key = kafkaRecord.key(); + TransactionStatusUpdatedEvent event = kafkaRecord.value(); + + LOGGER.infof("Received TransactionStatusUpdatedEvent with key: %s", key); + + try { + updateTransactionStatusUseCase.execute(event); + LOGGER.infof("Successfully processed TransactionStatusUpdatedEvent with key: %s", key); + } catch (Exception e) { + LOGGER.errorf(e, "Error processing TransactionStatusUpdatedEvent with key: %s", key); + throw e; + } + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistence.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistence.java new file mode 100644 index 0000000000..d3d4d50e86 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistence.java @@ -0,0 +1,83 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransactionPostgresRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.util.Optional; +import java.util.UUID; + +/** + * Persistence implementation for TransactionRepository using PostgreSQL. + */ +@ApplicationScoped +public class TransactionPersistence implements TransactionRepository { + + private final TransactionPostgresRepository repository; + + /** + * Constructor for TransactionPersistence. + * + * @param repository the PostgreSQL repository for transactions + */ + @Inject + public TransactionPersistence(TransactionPostgresRepository repository) { + this.repository = repository; + } + + @Override + @Transactional(Transactional.TxType.REQUIRED) + public Transaction save(Transaction tx) { + TransactionEntity entity = toEntity(tx); + TransactionEntity savedEntity = repository.save(entity); + return toDomain(savedEntity); + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public Optional findByExternalId(UUID externalId) { + return Optional.ofNullable(repository.findByTransactionExternalId(externalId)) + .map(TransactionPersistence::toDomain); + } + + private static TransactionEntity toEntity(Transaction domain) { + if (domain == null) { + return null; + } + + TransactionEntity entity = new TransactionEntity(); + entity.setTransactionExternalId(domain.getTransactionExternalId()); + entity.setAccountExternalIdDebit(domain.getAccountExternalIdDebit()); + entity.setAccountExternalIdCredit(domain.getAccountExternalIdCredit()); + entity.setTransferTypeId(domain.getTransferTypeId()); + entity.setTransactionStatusId(domain.getTransactionStatusId()); + entity.setValue(domain.getValue()); + return entity; + } + + private static Transaction toDomain(TransactionEntity entity) { + if (entity == null) { + return null; + } + + return Transaction.builder() + .transactionExternalId(entity.getTransactionExternalId()) + .accountExternalIdDebit(entity.getAccountExternalIdDebit()) + .accountExternalIdCredit(entity.getAccountExternalIdCredit()) + .transferTypeId(entity.getTransferTypeId()) + .transactionStatusId(entity.getTransactionStatusId()) + .value(entity.getValue()) + .createdAt(entity.getCreatedAt()) + .build(); + } + + @Override + @Transactional(Transactional.TxType.REQUIRED) + public int updateStatus(UUID externalId, Integer newStatusId) { + return repository.updateStatusByExternalId(externalId, newStatusId); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistence.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistence.java new file mode 100644 index 0000000000..5d5574a1c7 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistence.java @@ -0,0 +1,56 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionStatusEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransactionStatusPostgresRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.util.Optional; + +/** + * Persistence implementation for TransactionStatus. + */ +@ApplicationScoped +public class TransactionStatusPersistence implements TransactionStatusRepository { + + private final TransactionStatusPostgresRepository repository; + + /** + * Constructor for TransactionStatusPersistence. + * + * @param repository the PostgreSQL repository for transaction statuses + */ + @Inject + public TransactionStatusPersistence(TransactionStatusPostgresRepository repository) { + this.repository = repository; + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public Optional findByCode(String code) { + return Optional.ofNullable(repository.findByCode(code)) + .map(TransactionStatusPersistence::toDomain); + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public Optional findById(Integer id) { + return Optional.ofNullable(repository.findById(id)) + .map(TransactionStatusPersistence::toDomain); + } + + private static TransactionStatus toDomain(TransactionStatusEntity entity) { + if (entity == null) { + return null; + } + + return TransactionStatus.builder() + .transactionStatusId(entity.getTransactionStatusId()) + .code(entity.getCode()) + .name(entity.getName()) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistence.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistence.java new file mode 100644 index 0000000000..f75c1f4070 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistence.java @@ -0,0 +1,59 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.repository.TransferTypeRepository; +import com.yape.services.transaction.infrastructure.persistence.entity.TransferTypeEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransferTypePostgresRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.Optional; + +/** + * Persistence implementation for TransferTypeRepository using PostgreSQL. + */ +@ApplicationScoped +public class TransferTypePersistence implements TransferTypeRepository { + + private final TransferTypePostgresRepository repository; + + /** + * Constructor for TransferTypePersistence. + * + * @param repository the PostgreSQL repository for transfer types + */ + @Inject + public TransferTypePersistence(TransferTypePostgresRepository repository) { + this.repository = repository; + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public Optional findById(Integer transferTypeId) { + return Optional.ofNullable(repository.findByTransferTypeId(transferTypeId)) + .map(TransferTypePersistence::toDomain); + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public List findAll() { + List entities = repository.findAllTransferTypes(); + return entities.stream() + .map(TransferTypePersistence::toDomain) + .toList(); + } + + private static TransferType toDomain(TransferTypeEntity entity) { + if (entity == null) { + return null; + } + + return TransferType.builder() + .transferTypeId(entity.getTransferTypeId()) + .code(entity.getCode()) + .name(entity.getName()) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionEntity.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionEntity.java new file mode 100644 index 0000000000..6222c7b1bb --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionEntity.java @@ -0,0 +1,54 @@ +package com.yape.services.transaction.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +/** + * Entity class representing a transaction in the database. + */ +@Entity +@Table(name = "transaction") +@Getter +@Setter +public class TransactionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "transaction_id") + private Integer transactionId; + + @Column(name = "transaction_external_id", nullable = false, unique = true) + private UUID transactionExternalId; + + @Column(name = "account_external_id_debit", nullable = false) + private UUID accountExternalIdDebit; + + @Column(name = "account_external_id_credit", nullable = false) + private UUID accountExternalIdCredit; + + @Column(name = "transfer_type_id", nullable = false) + private Integer transferTypeId; + + @Column(name = "transaction_status_id", nullable = false) + private Integer transactionStatusId; + + @Column(nullable = false, precision = 19, scale = 4) + private BigDecimal value; + + // @Column(name = "created_at", insertable = false, updatable = false) + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionStatusEntity.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionStatusEntity.java new file mode 100644 index 0000000000..1c83e366f0 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionStatusEntity.java @@ -0,0 +1,32 @@ +package com.yape.services.transaction.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +/** + * Entity class representing a transaction status in the transaction service. + */ +@Entity +@Table(name = "transaction_status") +@Getter +@Setter +public class TransactionStatusEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "transaction_status_id") + private Integer transactionStatusId; + + @Column(name = "code", unique = true, nullable = false, length = 20) + private String code; + + @Column(name = "name", nullable = false, length = 50) + private String name; + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransferTypeEntity.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransferTypeEntity.java new file mode 100644 index 0000000000..eb74c30f68 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransferTypeEntity.java @@ -0,0 +1,32 @@ +package com.yape.services.transaction.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +/** + * Entity class representing a transfer type in the transaction service. + */ +@Entity +@Table(name = "transfer_type") +@Getter +@Setter +public class TransferTypeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "transfer_type_id") + private Integer transferTypeId; + + @Column(name = "code", unique = true, nullable = false, length = 20) + private String code; + + @Column(name = "name", nullable = false, length = 50) + private String name; + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepository.java new file mode 100644 index 0000000000..7fb2870ebd --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepository.java @@ -0,0 +1,48 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.UUID; + +/** + * Repository for transaction entities in PostgreSQL using Panache. + */ +@ApplicationScoped +public class TransactionPostgresRepository + implements PanacheRepositoryBase { + + /** + * Finds a transaction entity by its external ID. + * + * @param transactionExternalId the external ID of the transaction + * @return the transaction entity + */ + public TransactionEntity findByTransactionExternalId(UUID transactionExternalId) { + return find("transactionExternalId", transactionExternalId).firstResult(); + } + + /** + * Saves a transaction entity. + * + * @param entity the entity to save + * @return the saved entity + */ + public TransactionEntity save(TransactionEntity entity) { + persist(entity); + return entity; + } + + /** + * Updates the status of a transaction by its external ID. + * + * @param transactionExternalId the external ID of the transaction + * @param newStatusId the new status ID to set + * @return the number of updated records + */ + public int updateStatusByExternalId(UUID transactionExternalId, Integer newStatusId) { + return update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, transactionExternalId); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepository.java new file mode 100644 index 0000000000..be7ce5ba85 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepository.java @@ -0,0 +1,34 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionStatusEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Repository for transaction status entities in PostgreSQL using Panache. + */ +@ApplicationScoped +public class TransactionStatusPostgresRepository + implements PanacheRepositoryBase { + + /** + * Finds a transaction status entity by its code. + * + * @param code the code of the transaction status + * @return the transaction status entity + */ + public TransactionStatusEntity findByCode(String code) { + return find("code", code).firstResult(); + } + + /** + * Finds a transaction status entity by its ID. + * + * @param id the ID of the transaction status + * @return the transaction status entity + */ + public TransactionStatusEntity findById(Integer id) { + return find("transactionStatusId", id).firstResult(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepository.java new file mode 100644 index 0000000000..54b617d448 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepository.java @@ -0,0 +1,34 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransferTypeEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * Repository for transfer type entities in PostgreSQL using Panache. + */ +@ApplicationScoped +public class TransferTypePostgresRepository + implements PanacheRepositoryBase { + + /** + * Finds a transfer type entity by its transfer ID. + * + * @param transferTypeId the ID of the transfer type + * @return the transfer type entity + */ + public TransferTypeEntity findByTransferTypeId(Integer transferTypeId) { + return find("transferTypeId", transferTypeId).firstResult(); + } + + /** + * Finds all transfer type entities. + * + * @return list of all transfer type entities + */ + public List findAllTransferTypes() { + return listAll(); + } + +} diff --git a/ms-transaction/src/main/resources/application-local.yml b/ms-transaction/src/main/resources/application-local.yml new file mode 100644 index 0000000000..8d885de9e0 --- /dev/null +++ b/ms-transaction/src/main/resources/application-local.yml @@ -0,0 +1,118 @@ +# This configuration is used for local development environment. +# Profile: local (activate with -Dquarkus.profile=local) + +# Quarkus Configuration +quarkus: + + # Datasource Configuration (PostgreSQL) + datasource: + db-kind: postgresql + username: "${QUARKUS_DATASOURCE_USERNAME:postgres}" + password: "${QUARKUS_DATASOURCE_PASSWORD:postgres}" + jdbc: + url: "${QUARKUS_DATASOURCE_JDBC_URL:jdbc:postgresql://localhost:5432/yape_transactions}" + max-size: 16 + min-size: 4 + initial-size: 4 + idle-removal-interval: 2M + max-lifetime: 30M + + # Redis Configuration + redisson: + threads: 16 + netty-threads: 32 + single-server-config: + address: "${QUARKUS_REDISSON_SINGLE_SERVER_CONFIG_ADDRESS:redis://localhost:6379}" + + flyway: + migrate-at-start: true + locations: classpath:db/migration + baseline-on-migrate: true + + # Hibernate ORM Configuration + hibernate-orm: + log: + sql: false + + devservices: + enabled: false + + # Logging Configuration + log: + level: INFO + category: + "com.yape": + level: DEBUG + "org.hibernate.SQL": + level: ERROR + "io.smallrye.reactive.messaging": + level: DEBUG + + # SmallRye GraphQL Configuration + smallrye-graphql: + error-extension-fields: + - exception + - classification + - code + show-runtime-exception-message: + - com.yape.services.shared.exception.BusinessException + - com.yape.services.shared.exception.ValidationException + - com.yape.services.shared.exception.ResourceNotFoundException + +application: + cache: + transaction: + map-name: "transactions" + prefix: "transaction:" + ttl: + pending: 300 + approved: 3600 + rejected: 3600 + transfer-type: + map-name: "transfer-types" + prefix: "transfer_type:" + ttl: 86400 + +# Kafka / Event Streaming Configuration +kafka: + bootstrap: + servers: "${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}" + schema: + registry: + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" + +# MicroProfile Reactive Messaging Configuration +mp: + messaging: + outgoing: + transaction-producer: + connector: smallrye-kafka + topic: transaction.created + key: + serializer: org.apache.kafka.common.serialization.StringSerializer + value: + serializer: io.confluent.kafka.serializers.KafkaAvroSerializer + schema: + registry: + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" + + incoming: + transaction-status-consumer: + connector: smallrye-kafka + topic: transaction.status + group: + id: ms-transaction-group + auto: + offset: + reset: earliest + failure-strategy: ignore + key: + deserializer: org.apache.kafka.common.serialization.StringDeserializer + value: + deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer + schema: + registry: + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" + specific: + avro: + reader: true diff --git a/ms-transaction/src/main/resources/application.yml b/ms-transaction/src/main/resources/application.yml new file mode 100644 index 0000000000..2662a4a99f --- /dev/null +++ b/ms-transaction/src/main/resources/application.yml @@ -0,0 +1,25 @@ +quarkus: + application: + name: ms-transaction + smallrye-health: + root-path: /health + ui: + enabled: false + http: + root-path: /ms-transaction + port: 18080 + profile: local + smallrye-graphql: + error-extension-fields: + - exception + - classification + - code + show-runtime-exception-message: + - com.yape.services.shared.exception.BusinessException + - com.yape.services.shared.exception.ValidationException + - com.yape.services.shared.exception.ResourceNotFoundException + +info: + project: + artifact: ${project.artifactId} + version: ${project.version} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/db/migration/V1.0__create_tables.sql b/ms-transaction/src/main/resources/db/migration/V1.0__create_tables.sql new file mode 100644 index 0000000000..fa402f6a09 --- /dev/null +++ b/ms-transaction/src/main/resources/db/migration/V1.0__create_tables.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS transaction_status +( + transaction_status_id SERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transfer_type +( + transfer_type_id SERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transaction +( + transaction_id SERIAL PRIMARY KEY, + transaction_external_id UUID NOT NULL UNIQUE, + account_external_id_debit UUID NOT NULL, + account_external_id_credit UUID NOT NULL, + transfer_type_id INT NOT NULL, + transaction_status_id INT NOT NULL, + value DECIMAL(19, 4) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_transfer_type FOREIGN KEY (transfer_type_id) + REFERENCES transfer_type (transfer_type_id), + CONSTRAINT fk_transaction_status FOREIGN KEY (transaction_status_id) + REFERENCES transaction_status (transaction_status_id), + CONSTRAINT chk_value CHECK (value > 0) +); \ No newline at end of file diff --git a/ms-transaction/src/main/resources/db/migration/V1.1__insert_tables.sql b/ms-transaction/src/main/resources/db/migration/V1.1__insert_tables.sql new file mode 100644 index 0000000000..1bc844f4fc --- /dev/null +++ b/ms-transaction/src/main/resources/db/migration/V1.1__insert_tables.sql @@ -0,0 +1,9 @@ +INSERT INTO transaction_status (code, name) +VALUES ('PENDING', 'Pendiente'), + ('APPROVED', 'Aprobado'), + ('REJECTED', 'Rechazado'); + +INSERT INTO transfer_type (transfer_type_id, code, name) +VALUES (1, 'TRANSFER', 'Transferencia'), + (2, 'PAYMENT', 'Pago'), + (3, 'DEPOSIT', 'Depósito'); diff --git a/ms-transaction/src/main/resources/db/migration/V1.2__create_indexes.sql b/ms-transaction/src/main/resources/db/migration/V1.2__create_indexes.sql new file mode 100644 index 0000000000..12ee13ca36 --- /dev/null +++ b/ms-transaction/src/main/resources/db/migration/V1.2__create_indexes.sql @@ -0,0 +1,5 @@ +CREATE INDEX IF NOT EXISTS idx_transaction_status_code ON transaction_status (code); + +CREATE INDEX IF NOT EXISTS idx_transfer_type_id ON transfer_type (transfer_type_id); + +CREATE INDEX IF NOT EXISTS idx_transaction_external_id ON transaction (transaction_external_id); \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/directive.graphqls b/ms-transaction/src/main/resources/graphql-client/directive.graphqls new file mode 100644 index 0000000000..75a96a9b14 --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/directive.graphqls @@ -0,0 +1,6 @@ +""" +Indicates the minimum allowed value for a field or argument +""" +directive @Min( + value : BigDecimal = -2147483648.00 +) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/scalar.graphqls b/ms-transaction/src/main/resources/graphql-client/scalar.graphqls new file mode 100644 index 0000000000..c64c0eb0f7 --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/scalar.graphqls @@ -0,0 +1,23 @@ +""" +Universal Unique Identifier (UUID) +Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +""" +scalar UUID + +""" +Timestamp in ISO 8601 format +Format: yyyy-MM-dd'T'HH:mm:ss.SSSZ +""" +scalar DateTime + +""" +Date in ISO 8601 format +Format: yyyy-MM-dd +""" +scalar Date + +""" +Monetary value with decimal precision +Example: 1500.50 +""" +scalar BigDecimal \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls new file mode 100644 index 0000000000..d4d0efc455 --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls @@ -0,0 +1,23 @@ +# ============================================================================= +# Transaction Domain - Input Definitions +# ============================================================================= + +""" +Input for creating a new financial transaction. +""" +input CreateTransaction { + """Identifier of the debit account""" + accountExternalIdDebit: UUID! + + """Identifier of the credit account""" + accountExternalIdCredit: UUID! + + """Transfer type identifier (category: TRANSFER, PAYMENT, DEPOSIT)""" + transferTypeId: Int! + + """ + Transaction amount. + - Minimum: 0 + """ + value: BigDecimal! @Min(value: 0) +} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls new file mode 100644 index 0000000000..cfe81b43cf --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls @@ -0,0 +1,24 @@ +# ============================================================================= +# Transaction Domain - Mutation Definitions +# ============================================================================= + +""" +Root Mutation type for transaction operations. +""" +type Mutation { + """ + Creates a new financial transaction. + + The transaction is created with PENDING status and sent to the + anti-fraud service for validation. Transactions with value > 1000 + will be automatically rejected. + + Possible errors: + - BAD_REQUEST: Invalid input data + - VALIDATION_ERROR: Business rule violation + """ + createTransaction( + """Transaction data""" + input: CreateTransaction! + ): Transaction! +} diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls new file mode 100644 index 0000000000..bfd5ba74bf --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls @@ -0,0 +1,24 @@ +# ============================================================================= +# Transaction Domain - Query Definitions +# ============================================================================= + +""" +Root Query type for transaction-related operations. +""" +type Query { + """ + Retrieves a transaction by its external identifier. + + Possible errors: + - NOT_FOUND: Transaction does not exist + """ + transaction( + """Unique external identifier of the transaction""" + transactionExternalId: UUID! + ): Transaction + + """ + Retrieves all available transfer types. + """ + transferTypes: [TransferType!]! +} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/subscriptions.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/subscriptions.graphqls new file mode 100644 index 0000000000..d72b2b565d --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/subscriptions.graphqls @@ -0,0 +1,26 @@ +# ============================================================================= +# Transaction Domain - Subscription Definitions +# ============================================================================= + +""" +Root Subscription type for real-time transaction updates. +""" +type Subscription { + """ + Subscribe to status changes of a specific transaction. + + Enables real-time notifications when the anti-fraud service + updates the transaction status. + + Security: + - Requires authentication via token in WebSocket connection payload + + Emitted events: + - PENDING → APPROVED: Transaction approved by anti-fraud + - PENDING → REJECTED: Transaction rejected by anti-fraud (value > 1000) + """ + transactionStatusChanged( + """Identifier of the transaction to observe""" + transactionExternalId: UUID! + ): TransactionStatusChangedEvent! @authenticated +} diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls new file mode 100644 index 0000000000..f687375ddd --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls @@ -0,0 +1,66 @@ +# ============================================================================= +# Transaction Domain - Type Definitions +# ============================================================================= + +""" +Represents a financial transaction in the system. +""" +type Transaction { + """Unique external identifier of the transaction""" + transactionExternalId: UUID! + + """Type of the transaction""" + transactionType: TransactionType! + + """Current status of the transaction""" + transactionStatus: TransactionStatus! + + """Amount of the transaction""" + value: BigDecimal! + + """Date and time of the transaction creation""" + createdAt: Date! +} + +""" +Details about a transfer type. +""" +type TransferType { + """Identifier of the transfer type""" + transferTypeId: ID! + """Name of the transfer type""" + name: String! +} + +""" +Details about a transaction type. +""" +type TransactionType { + """Name of the transaction type""" + name: String! +} + +""" +Details about a transaction status. +""" +type TransactionStatus { + """Name of the status""" + name: String! +} + +""" +Event emitted when a transaction status changes (for Subscriptions). +""" +type TransactionStatusChangedEvent { + """The updated transaction""" + transaction: Transaction! + + """Previous status""" + previousStatus: String! + + """New status""" + newStatus: String! + + """Date and time of the change""" + changedAt: DateTime! +} diff --git a/ms-transaction/src/test/java/com/yape/services/expose/graphql/MutationResolverImplTest.java b/ms-transaction/src/test/java/com/yape/services/expose/graphql/MutationResolverImplTest.java new file mode 100644 index 0000000000..b749b19047 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/expose/graphql/MutationResolverImplTest.java @@ -0,0 +1,144 @@ +package com.yape.services.expose.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.application.usecase.CreateTransactionUseCase; +import com.yape.services.transaction.graphql.model.CreateTransaction; +import com.yape.services.transaction.graphql.model.Transaction; +import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MutationResolverImplTest { + + @Mock + private CreateTransactionUseCase createTransactionUseCase; + @Mock + private CurrentVertxRequest currentVertxRequest; + @Mock + private RoutingContext routingContext; + @Mock + private HttpServerRequest httpServerRequest; + + @Captor + private ArgumentCaptor metaDataCaptor; + + private MutationResolverImpl resolver; + + @BeforeEach + void setUp() { + resolver = new MutationResolverImpl(createTransactionUseCase, currentVertxRequest); + } + + @Nested + @DisplayName("createTransaction") + class CreateTransactionTests { + + @Test + @DisplayName("should delegate to use case and return created transaction") + void shouldDelegateToUseCaseAndReturnCreatedTransaction() { + // Arrange + CreateTransaction input = createInput(); + Transaction expectedTransaction = createTransaction(); + + when(currentVertxRequest.getCurrent()).thenReturn(routingContext); + when(routingContext.request()).thenReturn(httpServerRequest); + when(httpServerRequest.getHeader("Authorization")).thenReturn("Bearer token"); + when(httpServerRequest.getHeader("Request-ID")).thenReturn("req-123"); + when(httpServerRequest.getHeader("Request-Date")).thenReturn("2024-01-01"); + when(createTransactionUseCase.execute(eq(input), any(RequestMetaData.class))) + .thenReturn(expectedTransaction); + + // Act + Transaction result = resolver.createTransaction(input); + + // Assert + assertEquals(expectedTransaction, result); + verify(createTransactionUseCase).execute(eq(input), any(RequestMetaData.class)); + } + + @Test + @DisplayName("should extract headers and pass as metadata") + void shouldExtractHeadersAndPassAsMetadata() { + // Arrange + CreateTransaction input = createInput(); + Transaction expectedTransaction = createTransaction(); + + when(currentVertxRequest.getCurrent()).thenReturn(routingContext); + when(routingContext.request()).thenReturn(httpServerRequest); + when(httpServerRequest.getHeader("Authorization")).thenReturn("Bearer my-token"); + when(httpServerRequest.getHeader("Request-ID")).thenReturn("request-456"); + when(httpServerRequest.getHeader("Request-Date")).thenReturn("2024-02-15"); + when(createTransactionUseCase.execute(eq(input), metaDataCaptor.capture())) + .thenReturn(expectedTransaction); + + // Act + resolver.createTransaction(input); + + // Assert + RequestMetaData capturedMetaData = metaDataCaptor.getValue(); + assertEquals("Bearer my-token", capturedMetaData.authorization()); + assertEquals("request-456", capturedMetaData.requestId()); + assertEquals("2024-02-15", capturedMetaData.requestDate()); + } + + @Test + @DisplayName("should handle missing headers") + void shouldHandleMissingHeaders() { + // Arrange + CreateTransaction input = createInput(); + Transaction expectedTransaction = createTransaction(); + + when(currentVertxRequest.getCurrent()).thenReturn(routingContext); + when(routingContext.request()).thenReturn(httpServerRequest); + when(httpServerRequest.getHeader("Authorization")).thenReturn(null); + when(httpServerRequest.getHeader("Request-ID")).thenReturn(null); + when(httpServerRequest.getHeader("Request-Date")).thenReturn(null); + when(createTransactionUseCase.execute(eq(input), metaDataCaptor.capture())) + .thenReturn(expectedTransaction); + + // Act + Transaction result = resolver.createTransaction(input); + + // Assert + assertEquals(expectedTransaction, result); + RequestMetaData capturedMetaData = metaDataCaptor.getValue(); + assertNull(capturedMetaData.authorization()); + assertNull(capturedMetaData.requestId()); + assertNull(capturedMetaData.requestDate()); + } + } + + private CreateTransaction createInput() { + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit(UUID.randomUUID().toString()); + input.setAccountExternalIdCredit(UUID.randomUUID().toString()); + input.setTransferTypeId(1); + input.setValue("100.00"); + return input; + } + + private Transaction createTransaction() { + Transaction tx = new Transaction(); + tx.setTransactionExternalId(UUID.randomUUID().toString()); + tx.setValue("100.00"); + return tx; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/expose/graphql/QueryResolverImplTest.java b/ms-transaction/src/test/java/com/yape/services/expose/graphql/QueryResolverImplTest.java new file mode 100644 index 0000000000..247f800163 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/expose/graphql/QueryResolverImplTest.java @@ -0,0 +1,126 @@ +package com.yape.services.expose.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.application.usecase.GetTransactionUseCase; +import com.yape.services.transaction.application.usecase.GetTransferTypesUseCase; +import com.yape.services.transaction.graphql.model.Transaction; +import com.yape.services.transaction.graphql.model.TransferType; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class QueryResolverImplTest { + + @Mock + private GetTransferTypesUseCase getTransferTypesUseCase; + @Mock + private GetTransactionUseCase getTransactionUseCase; + + private QueryResolverImpl resolver; + + private static final String TRANSACTION_EXTERNAL_ID = UUID.randomUUID().toString(); + + @BeforeEach + void setUp() { + resolver = new QueryResolverImpl(getTransferTypesUseCase, getTransactionUseCase); + } + + @Nested + @DisplayName("transaction") + class TransactionQueryTests { + + @Test + @DisplayName("should delegate to use case and return transaction") + void shouldDelegateToUseCaseAndReturnTransaction() { + // Arrange + Transaction expectedTransaction = createTransaction(); + when(getTransactionUseCase.execute(TRANSACTION_EXTERNAL_ID)) + .thenReturn(expectedTransaction); + + // Act + Transaction result = resolver.transaction(TRANSACTION_EXTERNAL_ID); + + // Assert + assertEquals(expectedTransaction, result); + verify(getTransactionUseCase).execute(TRANSACTION_EXTERNAL_ID); + } + + @Test + @DisplayName("should pass transaction ID to use case") + void shouldPassTransactionIdToUseCase() { + // Arrange + String specificId = "specific-transaction-id"; + Transaction expectedTransaction = createTransaction(); + when(getTransactionUseCase.execute(specificId)) + .thenReturn(expectedTransaction); + + // Act + resolver.transaction(specificId); + + // Assert + verify(getTransactionUseCase).execute(specificId); + } + } + + @Nested + @DisplayName("transferTypes") + class TransferTypesQueryTests { + + @Test + @DisplayName("should delegate to use case and return transfer types") + void shouldDelegateToUseCaseAndReturnTransferTypes() { + // Arrange + List expectedTypes = List.of( + createTransferType(1, "INTERNAL"), + createTransferType(2, "EXTERNAL") + ); + when(getTransferTypesUseCase.execute()).thenReturn(expectedTypes); + + // Act + List result = resolver.transferTypes(); + + // Assert + assertEquals(2, result.size()); + assertEquals(expectedTypes, result); + verify(getTransferTypesUseCase).execute(); + } + + @Test + @DisplayName("should return empty list when no transfer types exist") + void shouldReturnEmptyListWhenNoTransferTypesExist() { + // Arrange + when(getTransferTypesUseCase.execute()).thenReturn(List.of()); + + // Act + List result = resolver.transferTypes(); + + // Assert + assertTrue(result.isEmpty()); + } + } + + private Transaction createTransaction() { + Transaction tx = new Transaction(); + tx.setTransactionExternalId(TRANSACTION_EXTERNAL_ID); + tx.setValue("100.00"); + return tx; + } + + private TransferType createTransferType(int id, String name) { + TransferType type = new TransferType(); + type.setTransferTypeId(String.valueOf(id)); + type.setName(name); + return type; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandlerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandlerTest.java new file mode 100644 index 0000000000..3373a74330 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandlerTest.java @@ -0,0 +1,147 @@ +package com.yape.services.transaction.application.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import java.math.BigDecimal; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreateTransactionCommandHandler") +class CreateTransactionCommandHandlerTest { + + @Mock + private TransactionRepository repository; + @Mock + private TransactionCacheService cacheService; + + @Captor + private ArgumentCaptor transactionCaptor; + + private CreateTransactionCommandHandler handler; + + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final int TRANSFER_TYPE_ID = 1; + private static final int TRANSACTION_STATUS_ID = 1; + private static final String STATUS_CODE = "PENDING"; + private static final BigDecimal VALUE = new BigDecimal("150.75"); + + @BeforeEach + void setUp() { + handler = new CreateTransactionCommandHandler(repository, cacheService); + } + + @Test + @DisplayName("should create transaction and save to repository") + void shouldCreateTransactionAndSaveToRepository() { + // Arrange + CreateTransactionCommand command = createCommand(); + Transaction savedTransaction = createSavedTransaction(); + + when(repository.save(any(Transaction.class))).thenReturn(savedTransaction); + + // Act + Transaction result = handler.handle(command); + + // Assert + assertEquals(savedTransaction, result); + verify(repository).save(transactionCaptor.capture()); + + Transaction capturedTransaction = transactionCaptor.getValue(); + assertEquals(DEBIT_ACCOUNT_ID, capturedTransaction.getAccountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID, capturedTransaction.getAccountExternalIdCredit()); + assertEquals(TRANSFER_TYPE_ID, capturedTransaction.getTransferTypeId()); + assertEquals(TRANSACTION_STATUS_ID, capturedTransaction.getTransactionStatusId()); + assertEquals(0, capturedTransaction.getValue().compareTo(VALUE)); + assertNotNull(capturedTransaction.getTransactionExternalId()); + } + + @Test + @DisplayName("should cache transaction after saving") + void shouldCacheTransactionAfterSaving() { + // Arrange + CreateTransactionCommand command = createCommand(); + Transaction savedTransaction = createSavedTransaction(); + + when(repository.save(any(Transaction.class))).thenReturn(savedTransaction); + + // Act + handler.handle(command); + + // Assert + verify(cacheService).saveTransaction(savedTransaction, STATUS_CODE); + } + + @Test + @DisplayName("should generate unique transaction external ID") + void shouldGenerateUniqueTransactionExternalId() { + // Arrange + CreateTransactionCommand command = createCommand(); + Transaction savedTransaction = createSavedTransaction(); + + when(repository.save(any(Transaction.class))).thenReturn(savedTransaction); + + // Act + handler.handle(command); + + // Assert + verify(repository).save(transactionCaptor.capture()); + Transaction capturedTransaction = transactionCaptor.getValue(); + assertNotNull(capturedTransaction.getTransactionExternalId()); + } + + @Test + @DisplayName("should return saved transaction from repository") + void shouldReturnSavedTransactionFromRepository() { + // Arrange + CreateTransactionCommand command = createCommand(); + Transaction savedTransaction = createSavedTransaction(); + UUID expectedExternalId = savedTransaction.getTransactionExternalId(); + + when(repository.save(any(Transaction.class))).thenReturn(savedTransaction); + + // Act + Transaction result = handler.handle(command); + + // Assert + assertEquals(expectedExternalId, result.getTransactionExternalId()); + assertEquals(0, result.getValue().compareTo(VALUE)); + } + + private CreateTransactionCommand createCommand() { + return new CreateTransactionCommand( + DEBIT_ACCOUNT_ID, + CREDIT_ACCOUNT_ID, + TRANSFER_TYPE_ID, + TRANSACTION_STATUS_ID, + STATUS_CODE, + VALUE + ); + } + + private Transaction createSavedTransaction() { + return Transaction.builder() + .transactionExternalId(UUID.randomUUID()) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(TRANSFER_TYPE_ID) + .transactionStatusId(TRANSACTION_STATUS_ID) + .value(VALUE) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapperTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapperTest.java new file mode 100644 index 0000000000..a3436952bb --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapperTest.java @@ -0,0 +1,167 @@ +package com.yape.services.transaction.application.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.graphql.model.TransactionType; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("GraphqlTransactionMapper") +class GraphqlTransactionMapperTest { + + private GraphqlTransactionMapper mapper; + + @BeforeEach + void setUp() { + mapper = new GraphqlTransactionMapperImpl(); + } + + @Test + @DisplayName("should convert UUID to string") + void shouldConvertUuidToString() { + // Arrange + UUID uuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + + // Act + String result = mapper.uuidToString(uuid); + + // Assert + assertEquals("123e4567-e89b-12d3-a456-426614174000", result); + } + + @Test + @DisplayName("should return null for null UUID") + void shouldReturnNullForNullUuid() { + // Act + String result = mapper.uuidToString(null); + + // Assert + assertNull(result); + } + + @ParameterizedTest(name = "{index} => value={0}, expected={1}") + @MethodSource("bigDecimalToStringProvider") + @DisplayName("should convert BigDecimal to string (parameterized)") + void shouldConvertBigDecimalToString(BigDecimal value, String expected) { + // Act + String result = mapper.bigDecimalToString(value); + // Assert + assertEquals(expected, result); + } + + static Stream bigDecimalToStringProvider() { + return Stream.of( + Arguments.of(new BigDecimal("1234.56"), "1234.56"), + Arguments.of(new BigDecimal("999999999999.99"), "999999999999.99"), + Arguments.of(null, null), + Arguments.of(new BigDecimal("1000"), "1000") + ); + } + + @Test + @DisplayName("should format LocalDateTime to ISO date") + void shouldFormatLocalDateTimeToIsoDate() { + // Arrange + LocalDateTime dateTime = LocalDateTime.of(2024, 6, 15, 10, 30, 45); + + // Act + String result = mapper.formatDate(dateTime); + + // Assert + assertEquals("2024-06-15", result); + } + + @Test + @DisplayName("should return null for null LocalDateTime") + void shouldReturnNullForNullLocalDateTime() { + // Act + String result = mapper.formatDate(null); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("should format date with single digit month and day") + void shouldFormatDateWithSingleDigitMonthAndDay() { + // Arrange + LocalDateTime dateTime = LocalDateTime.of(2024, 1, 5, 8, 15); + + // Act + String result = mapper.formatDate(dateTime); + + // Assert + assertEquals("2024-01-05", result); + } + + @Test + @DisplayName("should convert TransferType to TransactionType") + void shouldConvertTransferTypeToTransactionType() { + // Arrange + TransferType transferType = TransferType.builder() + .transferTypeId(1) + .code("INTERNAL") + .name("Internal Transfer") + .build(); + + // Act + TransactionType result = mapper.toTransactionType(transferType); + + // Assert + assertNotNull(result); + assertEquals("Internal Transfer", result.getName()); + } + + @Test + @DisplayName("should return null for null TransferType") + void shouldReturnNullForNullTransferType() { + // Act + TransactionType result = mapper.toTransactionType(null); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("should convert domain TransactionStatus to GraphQL TransactionStatus") + void shouldConvertDomainStatusToGraphqlStatus() { + // Arrange + TransactionStatus txStatus = TransactionStatus.builder() + .transactionStatusId(1) + .code("PENDING") + .name("Pending") + .build(); + + // Act + com.yape.services.transaction.graphql.model.TransactionStatus result = + mapper.toGraphqlStatus(txStatus); + + // Assert + assertNotNull(result); + assertEquals("Pending", result.getName()); + } + + @Test + @DisplayName("should return null for null TransactionStatus") + void shouldReturnNullForNullTransactionStatus() { + // Act + com.yape.services.transaction.graphql.model.TransactionStatus result = + mapper.toGraphqlStatus(null); + + // Assert + assertNull(result); + } + +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/TransactionMapperTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/TransactionMapperTest.java new file mode 100644 index 0000000000..ff7f2260e1 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/TransactionMapperTest.java @@ -0,0 +1,186 @@ +package com.yape.services.transaction.application.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import java.math.BigDecimal; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("TransactionMapper") +class TransactionMapperTest { + + private TransactionMapper mapper; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final BigDecimal VALUE = new BigDecimal("250.75"); + + @BeforeEach + void setUp() { + mapper = new TransactionMapper(); + } + + @Test + @DisplayName("should map transaction to event with all fields") + void shouldMapTransactionToEventWithAllFields() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertNotNull(event); + assertNotNull(event.getMetadata()); + assertNotNull(event.getPayload()); + // Verify metadata + assertNotNull(event.getMetadata().getEventId()); + assertEquals("TRANSACTION_CREATED", event.getMetadata().getEventType()); + assertEquals("ms-transaction", event.getMetadata().getSource()); + assertEquals("1.0.0", event.getMetadata().getVersion()); + assertEquals("req-123", event.getMetadata().getRequestId()); + assertNotNull(event.getMetadata().getEventTimestamp()); + // Verify payload + assertEquals( + TRANSACTION_EXTERNAL_ID.toString(), + event.getPayload().getTransactionExternalId() + ); + assertEquals(DEBIT_ACCOUNT_ID.toString(), event.getPayload().getAccountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID.toString(), + event.getPayload().getAccountExternalIdCredit()); + assertEquals(1, event.getPayload().getTransferTypeId()); + assertEquals("250.75", event.getPayload().getValue()); + assertEquals("PENDING", event.getPayload().getStatus().name()); + } + + @Test + @DisplayName("should handle null request metadata") + void shouldHandleNullRequestMetadata() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, null); + + // Assert + assertNotNull(event); + assertNull(event.getMetadata().getRequestId()); + } + + @Test + @DisplayName("should map APPROVED status correctly") + void shouldMapApprovedStatusCorrectly() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = TransactionStatus.builder() + .transactionStatusId(2) + .code("APPROVED") + .name("Approved") + .build(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertEquals("APPROVED", event.getPayload().getStatus().name()); + } + + @Test + @DisplayName("should map REJECTED status correctly") + void shouldMapRejectedStatusCorrectly() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = TransactionStatus.builder() + .transactionStatusId(3) + .code("REJECTED") + .name("Rejected") + .build(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertEquals("REJECTED", event.getPayload().getStatus().name()); + } + + @Test + @DisplayName("should generate unique event ID for each mapping") + void shouldGenerateUniqueEventIdForEachMapping() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event1 = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + TransactionCreatedEvent event2 = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertNotNull(event1.getMetadata().getEventId()); + assertNotNull(event2.getMetadata().getEventId()); + assertNotEquals(event1.getMetadata().getEventId(), event2.getMetadata().getEventId()); + } + + @Test + @DisplayName("should format value as plain string without scientific notation") + void shouldFormatValueAsPlainString() { + // Arrange + Transaction transaction = Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(1) + .transactionStatusId(1) + .value(new BigDecimal("1234567.89")) + .build(); + TransactionStatus status = createPendingStatus(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertEquals("1234567.89", event.getPayload().getValue()); + } + + private Transaction createTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(1) + .transactionStatusId(1) + .value(VALUE) + .build(); + } + + private TransactionStatus createPendingStatus() { + return TransactionStatus.builder() + .transactionStatusId(1) + .code("PENDING") + .name("Pending") + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionQueryHandlerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionQueryHandlerTest.java new file mode 100644 index 0000000000..8bc0eb907b --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionQueryHandlerTest.java @@ -0,0 +1,173 @@ +package com.yape.services.transaction.application.query; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionQueryHandlerTest { + + @Mock + private TransactionRepository transactionRepository; + @Mock + private TransactionStatusRepository transactionStatusRepository; + @Mock + private TransactionCacheService cacheService; + + private TransactionQueryHandler handler; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final int TRANSACTION_STATUS_ID = 1; + + @BeforeEach + void setUp() { + handler = new TransactionQueryHandler( + transactionRepository, + transactionStatusRepository, + cacheService + ); + } + + @Test + @DisplayName("should return transaction from cache when present") + void shouldReturnTransactionFromCacheWhenPresent() { + // Arrange + Transaction cachedTransaction = createTransaction(); + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(cachedTransaction)); + + // Act + Optional result = handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(cachedTransaction, result.get()); + verify(transactionRepository, never()).findByExternalId(TRANSACTION_EXTERNAL_ID); + } + + @Test + @DisplayName("should fetch from database on cache miss") + void shouldFetchFromDatabaseOnCacheMiss() { + // Arrange + Transaction dbTransaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(dbTransaction)); + when(transactionStatusRepository.findById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.of(status)); + + // Act + Optional result = handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(dbTransaction, result.get()); + verify(transactionRepository).findByExternalId(TRANSACTION_EXTERNAL_ID); + } + + @Test + @DisplayName("should cache transaction after fetching from database") + void shouldCacheTransactionAfterFetchingFromDatabase() { + // Arrange + Transaction dbTransaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(dbTransaction)); + when(transactionStatusRepository.findById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.of(status)); + + // Act + handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + verify(cacheService).saveTransaction(dbTransaction, "PENDING"); + } + + @Test + @DisplayName("should return empty when transaction not found in cache or database") + void shouldReturnEmptyWhenTransactionNotFound() { + // Arrange + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isEmpty()); + verify(cacheService, never()).saveTransaction( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); + } + + @Test + @DisplayName("should not cache when status not found") + void shouldNotCacheWhenStatusNotFound() { + // Arrange + Transaction dbTransaction = createTransaction(); + + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(dbTransaction)); + when(transactionStatusRepository.findById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + verify(cacheService, never()).saveTransaction( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); + } + + private Transaction createTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .transferTypeId(1) + .transactionStatusId(TRANSACTION_STATUS_ID) + .value(new BigDecimal("100.00")) + .createdAt(LocalDateTime.now()) + .build(); + } + + private TransactionStatus createPendingStatus() { + return TransactionStatus.builder() + .transactionStatusId(TRANSACTION_STATUS_ID) + .code("PENDING") + .name("Pending") + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandlerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandlerTest.java new file mode 100644 index 0000000000..aa934a554b --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandlerTest.java @@ -0,0 +1,141 @@ +package com.yape.services.transaction.application.query; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionStatusQueryHandlerTest { + + @Mock + private TransactionStatusRepository transactionStatusRepository; + + private TransactionStatusQueryHandler handler; + + @BeforeEach + void setUp() { + handler = new TransactionStatusQueryHandler(transactionStatusRepository); + } + + @Test + @DisplayName("should return status when found by code") + void shouldReturnStatusWhenFoundByCode() { + // Arrange + String code = "PENDING"; + TransactionStatus expectedStatus = createStatus(1, code, "Pending"); + when(transactionStatusRepository.findByCode(code)) + .thenReturn(Optional.of(expectedStatus)); + + // Act + Optional result = handler.getTransactionStatusByCode(code); + + // Assert + assertTrue(result.isPresent()); + assertEquals(code, result.get().getCode()); + verify(transactionStatusRepository).findByCode(code); + } + + @Test + @DisplayName("should return empty when status not found by code") + void shouldReturnEmptyWhenStatusNotFoundByCode() { + // Arrange + String code = "UNKNOWN"; + when(transactionStatusRepository.findByCode(code)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransactionStatusByCode(code); + + // Assert + assertFalse(result.isPresent()); + verify(transactionStatusRepository).findByCode(code); + } + + @Test + @DisplayName("should find APPROVED status") + void shouldFindApprovedStatus() { + // Arrange + String code = "APPROVED"; + TransactionStatus expectedStatus = createStatus(2, code, "Approved"); + when(transactionStatusRepository.findByCode(code)) + .thenReturn(Optional.of(expectedStatus)); + + // Act + Optional result = handler.getTransactionStatusByCode(code); + + // Assert + assertTrue(result.isPresent()); + assertEquals("APPROVED", result.get().getCode()); + } + + @Test + @DisplayName("should find REJECTED status") + void shouldFindRejectedStatus() { + // Arrange + String code = "REJECTED"; + TransactionStatus expectedStatus = createStatus(3, code, "Rejected"); + when(transactionStatusRepository.findByCode(code)) + .thenReturn(Optional.of(expectedStatus)); + + // Act + Optional result = handler.getTransactionStatusByCode(code); + + // Assert + assertTrue(result.isPresent()); + assertEquals("REJECTED", result.get().getCode()); + } + + @Test + @DisplayName("should return status when found by ID") + void shouldReturnStatusWhenFoundById() { + // Arrange + Integer id = 1; + TransactionStatus expectedStatus = createStatus(id, "PENDING", "Pending"); + when(transactionStatusRepository.findById(id)) + .thenReturn(Optional.of(expectedStatus)); + + // Act + Optional result = handler.getTransactionStatusById(id); + + // Assert + assertTrue(result.isPresent()); + assertEquals(id, result.get().getTransactionStatusId()); + verify(transactionStatusRepository).findById(id); + } + + @Test + @DisplayName("should return empty when status not found by ID") + void shouldReturnEmptyWhenStatusNotFoundById() { + // Arrange + Integer id = 999; + when(transactionStatusRepository.findById(id)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransactionStatusById(id); + + // Assert + assertFalse(result.isPresent()); + verify(transactionStatusRepository).findById(id); + } + + private TransactionStatus createStatus(int id, String code, String name) { + return TransactionStatus.builder() + .transactionStatusId(id) + .code(code) + .name(name) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransferTypeQueryHandlerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransferTypeQueryHandlerTest.java new file mode 100644 index 0000000000..2dc4057394 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransferTypeQueryHandlerTest.java @@ -0,0 +1,169 @@ +package com.yape.services.transaction.application.query; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.repository.TransferTypeRepository; +import com.yape.services.transaction.domain.service.TransferTypeCacheService; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransferTypeQueryHandlerTest { + + @Mock + private TransferTypeRepository transferTypeRepository; + @Mock + private TransferTypeCacheService cacheService; + + private TransferTypeQueryHandler handler; + + private static final int TRANSFER_TYPE_ID = 1; + + @BeforeEach + void setUp() { + handler = new TransferTypeQueryHandler(transferTypeRepository, cacheService); + } + + @Test + @DisplayName("should return transfer type from cache when present") + void shouldReturnTransferTypeFromCacheWhenPresent() { + // Arrange + TransferType cachedTransferType = createTransferType(TRANSFER_TYPE_ID); + when(cacheService.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(cachedTransferType)); + + // Act + Optional result = handler.getTransferTypeById(TRANSFER_TYPE_ID); + + // Assert + assertTrue(result.isPresent()); + assertSame(cachedTransferType, result.get()); + verify(transferTypeRepository, never()).findById(TRANSFER_TYPE_ID); + } + + @Test + @DisplayName("should fetch from database on cache miss") + void shouldFetchFromDatabaseOnCacheMiss() { + // Arrange + TransferType dbTransferType = createTransferType(TRANSFER_TYPE_ID); + when(cacheService.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + when(transferTypeRepository.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(dbTransferType)); + + // Act + Optional result = handler.getTransferTypeById(TRANSFER_TYPE_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(dbTransferType, result.get()); + verify(transferTypeRepository).findById(TRANSFER_TYPE_ID); + } + + @Test + @DisplayName("should cache transfer type after fetching from database") + void shouldCacheTransferTypeAfterFetchingFromDatabase() { + // Arrange + TransferType dbTransferType = createTransferType(TRANSFER_TYPE_ID); + when(cacheService.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + when(transferTypeRepository.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(dbTransferType)); + + // Act + handler.getTransferTypeById(TRANSFER_TYPE_ID); + + // Assert + verify(cacheService).save(dbTransferType); + } + + @Test + @DisplayName("should return empty when transfer type not found") + void shouldReturnEmptyWhenTransferTypeNotFound() { + // Arrange + when(cacheService.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + when(transferTypeRepository.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransferTypeById(TRANSFER_TYPE_ID); + + // Assert + assertFalse(result.isPresent()); + verify(cacheService, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("should return transfer types from cache when present") + void shouldReturnTransferTypesFromCacheWhenPresent() { + // Arrange + List cachedTypes = List.of( + createTransferType(1), + createTransferType(2) + ); + when(cacheService.findAll()).thenReturn(Optional.of(cachedTypes)); + + // Act + List result = handler.getAll(); + + // Assert + assertEquals(2, result.size()); + assertEquals(cachedTypes, result); + verify(transferTypeRepository, never()).findAll(); + } + + @Test + @DisplayName("should cache all transfer types after fetching from database") + void shouldCacheAllTransferTypesAfterFetchingFromDatabase() { + // Arrange + List dbTypes = List.of( + createTransferType(1), + createTransferType(2) + ); + when(cacheService.findAll()).thenReturn(Optional.empty()); + when(transferTypeRepository.findAll()).thenReturn(dbTypes); + + // Act + handler.getAll(); + + // Assert + verify(cacheService).saveAll(dbTypes); + } + + @Test + @DisplayName("should return empty list when no transfer types found") + void shouldReturnEmptyListWhenNoTransferTypesFound() { + // Arrange + when(cacheService.findAll()).thenReturn(Optional.empty()); + when(transferTypeRepository.findAll()).thenReturn(List.of()); + + // Act + List result = handler.getAll(); + + // Assert + assertTrue(result.isEmpty()); + verify(cacheService).saveAll(List.of()); + } + + private TransferType createTransferType(int id) { + return TransferType.builder() + .transferTypeId(id) + .code("TRANSFER_" + id) + .name("Transfer Type " + id) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCaseTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCaseTest.java new file mode 100644 index 0000000000..c759da37f8 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCaseTest.java @@ -0,0 +1,291 @@ +package com.yape.services.transaction.application.usecase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.shared.exception.BusinessException; +import com.yape.services.shared.exception.ResourceNotFoundException; +import com.yape.services.shared.exception.ValidationException; +import com.yape.services.transaction.application.command.CreateTransactionCommand; +import com.yape.services.transaction.application.command.CreateTransactionCommandHandler; +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.application.mapper.GraphqlTransactionMapper; +import com.yape.services.transaction.application.mapper.TransactionMapper; +import com.yape.services.transaction.application.query.TransactionStatusQueryHandler; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.service.TransactionEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.graphql.model.CreateTransaction; +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CreateTransactionUseCaseTest { + + @Mock + private CreateTransactionCommandHandler commandHandler; + @Mock + private TransactionEventPublisher eventPublisher; + @Mock + private TransactionMapper transactionMapper; + @Mock + private GraphqlTransactionMapper graphqlMapper; + @Mock + private TransferTypeQueryHandler transferTypeQueryHandler; + @Mock + private TransactionStatusQueryHandler statusQueryHandler; + + @Captor + private ArgumentCaptor commandCaptor; + + private CreateTransactionUseCase useCase; + + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final int TRANSFER_TYPE_ID = 1; + private static final String VALID_AMOUNT = "100.50"; + + @BeforeEach + void setUp() { + useCase = new CreateTransactionUseCase( + commandHandler, + eventPublisher, + transactionMapper, + graphqlMapper, + transferTypeQueryHandler, + statusQueryHandler + ); + } + + @Test + @DisplayName("should create transaction successfully with valid input") + void shouldCreateTransactionSuccessfully() { + // Arrange + CreateTransaction input = createValidInput(); + RequestMetaData metaData = createMetaData(); + TransferType transferType = createTransferType(); + TransactionStatus pendingStatus = createPendingStatus(); + Transaction savedTransaction = createSavedTransaction(); + TransactionCreatedEvent event = createEvent(); + var expectedGraphqlResponse = createGraphqlTransaction(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(statusQueryHandler.getTransactionStatusByCode("PENDING")) + .thenReturn(Optional.of(pendingStatus)); + when(commandHandler.handle(any(CreateTransactionCommand.class))) + .thenReturn(savedTransaction); + when(transactionMapper.toTransactionCreatedEvent(savedTransaction, pendingStatus, metaData)) + .thenReturn(event); + when(graphqlMapper.toGraphqlModel(savedTransaction, transferType, pendingStatus)) + .thenReturn(expectedGraphqlResponse); + + // Act + var result = useCase.execute(input, metaData); + + // Assert + assertEquals(expectedGraphqlResponse, result); + verify(commandHandler).handle(commandCaptor.capture()); + verify(eventPublisher).publishTransactionCreated(event); + + CreateTransactionCommand capturedCommand = commandCaptor.getValue(); + assertEquals(DEBIT_ACCOUNT_ID, capturedCommand.accountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID, capturedCommand.accountExternalIdCredit()); + assertEquals(new BigDecimal(VALID_AMOUNT), capturedCommand.value()); + } + + @Test + @DisplayName("should throw ResourceNotFoundException when transfer type not found") + void shouldThrowWhenTransferTypeNotFound() { + // Arrange + CreateTransaction input = createValidInput(); + RequestMetaData metaData = createMetaData(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(ResourceNotFoundException.class, () -> useCase.execute(input, metaData)); + + verify(commandHandler, never()).handle(any()); + verify(eventPublisher, never()).publishTransactionCreated(any()); + } + + @Test + @DisplayName("should throw BusinessException when PENDING status not configured") + void shouldThrowWhenPendingStatusNotConfigured() { + // Arrange + CreateTransaction input = createValidInput(); + RequestMetaData metaData = createMetaData(); + TransferType transferType = createTransferType(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(statusQueryHandler.getTransactionStatusByCode("PENDING")) + .thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(BusinessException.class, () -> useCase.execute(input, metaData)); + + verify(commandHandler, never()).handle(any()); + } + + @ParameterizedTest + @MethodSource("invalidAmountsProvider") + @DisplayName("should throw ValidationException for invalid amounts") + void shouldThrowValidationExceptionForInvalidAmounts(String invalidAmount) { + // Arrange + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit(DEBIT_ACCOUNT_ID.toString()); + input.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()); + input.setTransferTypeId(TRANSFER_TYPE_ID); + input.setValue(invalidAmount); + RequestMetaData metaData = createMetaData(); + // Act & Assert + assertThrows(ValidationException.class, () -> useCase.execute(input, metaData)); + } + + static Stream invalidAmountsProvider() { + return Stream.of(null, " ", "invalid-amount", "-10.00"); + } + + @Test + @DisplayName("should throw ValidationException when debit account ID is null") + void shouldThrowWhenDebitAccountIdIsNull() { + // Arrange + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit(null); + input.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()); + input.setTransferTypeId(TRANSFER_TYPE_ID); + input.setValue(VALID_AMOUNT); + + RequestMetaData metaData = createMetaData(); + TransferType transferType = createTransferType(); + TransactionStatus pendingStatus = createPendingStatus(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(statusQueryHandler.getTransactionStatusByCode("PENDING")) + .thenReturn(Optional.of(pendingStatus)); + + // Act & Assert + assertThrows(ValidationException.class, () -> useCase.execute(input, metaData)); + } + + @Test + @DisplayName("should throw ValidationException when debit account ID has invalid UUID format") + void shouldThrowWhenDebitAccountIdHasInvalidFormat() { + // Arrange + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit("invalid-uuid"); + input.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()); + input.setTransferTypeId(TRANSFER_TYPE_ID); + input.setValue(VALID_AMOUNT); + + RequestMetaData metaData = createMetaData(); + TransferType transferType = createTransferType(); + TransactionStatus pendingStatus = createPendingStatus(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(statusQueryHandler.getTransactionStatusByCode("PENDING")) + .thenReturn(Optional.of(pendingStatus)); + + // Act & Assert + assertThrows(ValidationException.class, () -> useCase.execute(input, metaData)); + } + + private CreateTransaction createValidInput() { + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit(DEBIT_ACCOUNT_ID.toString()); + input.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()); + input.setTransferTypeId(TRANSFER_TYPE_ID); + input.setValue(VALID_AMOUNT); + return input; + } + + private TransferType createTransferType() { + return TransferType.builder() + .transferTypeId(TRANSFER_TYPE_ID) + .code("TRANSFER") + .name("Transfer") + .build(); + } + + private TransactionStatus createPendingStatus() { + return TransactionStatus.builder() + .transactionStatusId(1) + .code("PENDING") + .name("Pending") + .build(); + } + + private Transaction createSavedTransaction() { + return Transaction.builder() + .transactionExternalId(UUID.randomUUID()) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(TRANSFER_TYPE_ID) + .transactionStatusId(1) + .value(new BigDecimal(VALID_AMOUNT)) + .build(); + } + + private TransactionCreatedEvent createEvent() { + com.yape.services.common.events.EventMetadata metadata = + com.yape.services.common.events.EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_CREATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-transaction") + .setVersion("1.0.0") + .setRequestId("request-123") + .build(); + + com.yape.services.transaction.events.TransactionCreatedPayload payload = + com.yape.services.transaction.events.TransactionCreatedPayload.newBuilder() + .setTransactionExternalId(UUID.randomUUID().toString()) + .setAccountExternalIdDebit(DEBIT_ACCOUNT_ID.toString()) + .setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()) + .setTransferTypeId(TRANSFER_TYPE_ID) + .setValue(VALID_AMOUNT) + .setStatus(com.yape.services.transaction.events.enums.TransactionStatus.PENDING) + .setCreatedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionCreatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } + + private com.yape.services.transaction.graphql.model.Transaction createGraphqlTransaction() { + var tx = new com.yape.services.transaction.graphql.model.Transaction(); + tx.setTransactionExternalId(UUID.randomUUID().toString()); + tx.setValue(VALID_AMOUNT); + return tx; + } + + private RequestMetaData createMetaData() { + return new RequestMetaData("Bearer token", "request-123", "2024-01-01"); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/GetTransactionUseCaseTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/GetTransactionUseCaseTest.java new file mode 100644 index 0000000000..975f6b6de4 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/GetTransactionUseCaseTest.java @@ -0,0 +1,181 @@ +package com.yape.services.transaction.application.usecase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import com.yape.services.shared.exception.ResourceNotFoundException; +import com.yape.services.transaction.application.mapper.GraphqlTransactionMapper; +import com.yape.services.transaction.application.query.TransactionQueryHandler; +import com.yape.services.transaction.application.query.TransactionStatusQueryHandler; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetTransactionUseCaseTest { + + @Mock + private TransactionQueryHandler transactionQueryHandler; + @Mock + private TransferTypeQueryHandler transferTypeQueryHandler; + @Mock + private TransactionStatusQueryHandler transactionStatusQueryHandler; + @Mock + private GraphqlTransactionMapper mapper; + + private GetTransactionUseCase useCase; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final int TRANSFER_TYPE_ID = 1; + private static final int TRANSACTION_STATUS_ID = 1; + + @BeforeEach + void setUp() { + useCase = new GetTransactionUseCase( + transactionQueryHandler, + transferTypeQueryHandler, + transactionStatusQueryHandler, + mapper + ); + } + + @Test + @DisplayName("should retrieve transaction successfully") + void shouldRetrieveTransactionSuccessfully() { + // Arrange + String externalIdStr = TRANSACTION_EXTERNAL_ID.toString(); + Transaction transaction = createTransaction(); + TransferType transferType = createTransferType(); + TransactionStatus status = createTransactionStatus(); + var expectedResult = createGraphqlTransaction(); + + when(transactionQueryHandler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(transaction)); + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(transactionStatusQueryHandler.getTransactionStatusById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.of(status)); + when(mapper.toGraphqlModel(transaction, transferType, status)) + .thenReturn(expectedResult); + + // Act + var result = useCase.execute(externalIdStr); + + // Assert + assertEquals(expectedResult, result); + } + + @Test + @DisplayName("should throw ResourceNotFoundException when transaction not found") + void shouldThrowWhenTransactionNotFound() { + // Arrange + String externalIdStr = TRANSACTION_EXTERNAL_ID.toString(); + + when(transactionQueryHandler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + + // Act / Assert + var exception = assertThrows(ResourceNotFoundException.class, + () -> useCase.execute(externalIdStr)); + var expectedMessage = "Transaction not found with ID: " + TRANSACTION_EXTERNAL_ID; + assertEquals(expectedMessage, exception.getMessage()); + } + + @Test + @DisplayName("should throw ResourceNotFoundException when transfer type not found") + void shouldThrowWhenTransferTypeNotFound() { + // Arrange + String externalIdStr = TRANSACTION_EXTERNAL_ID.toString(); + Transaction transaction = createTransaction(); + + when(transactionQueryHandler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(transaction)); + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + + // Act / Assert + var exception = assertThrows(ResourceNotFoundException.class, + () -> useCase.execute(externalIdStr)); + var expectedMessage = "TransferType not found with ID: " + TRANSFER_TYPE_ID; + assertEquals(expectedMessage, exception.getMessage()); + } + + @Test + @DisplayName("should throw ResourceNotFoundException when transaction status not found") + void shouldThrowWhenTransactionStatusNotFound() { + // Arrange + String externalIdStr = TRANSACTION_EXTERNAL_ID.toString(); + Transaction transaction = createTransaction(); + TransferType transferType = createTransferType(); + + when(transactionQueryHandler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(transaction)); + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(transactionStatusQueryHandler.getTransactionStatusById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.empty()); + + // Act / Assert + var exception = assertThrows(ResourceNotFoundException.class, () -> + useCase.execute(externalIdStr)); + var expectedMessage = "TransactionStatus not found with ID: " + TRANSACTION_STATUS_ID; + assertEquals(expectedMessage, exception.getMessage()); + } + + @Test + @DisplayName("should throw IllegalArgumentException when UUID format is invalid") + void shouldThrowWhenUuidFormatIsInvalid() { + // Arrange + String invalidUuid = "not-a-valid-uuid"; + + // Act / Assert + assertThrows(IllegalArgumentException.class, () -> useCase.execute(invalidUuid)); + } + + private Transaction createTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .transferTypeId(TRANSFER_TYPE_ID) + .transactionStatusId(TRANSACTION_STATUS_ID) + .value(new BigDecimal("100.00")) + .createdAt(LocalDateTime.now()) + .build(); + } + + private TransferType createTransferType() { + return TransferType.builder() + .transferTypeId(TRANSFER_TYPE_ID) + .code("TRANSFER") + .name("Transfer") + .build(); + } + + private TransactionStatus createTransactionStatus() { + return TransactionStatus.builder() + .transactionStatusId(TRANSACTION_STATUS_ID) + .code("PENDING") + .name("Pending") + .build(); + } + + private com.yape.services.transaction.graphql.model.Transaction createGraphqlTransaction() { + var tx = new com.yape.services.transaction.graphql.model.Transaction(); + tx.setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()); + tx.setValue("100.00"); + return tx; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCaseTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCaseTest.java new file mode 100644 index 0000000000..1949179c74 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCaseTest.java @@ -0,0 +1,201 @@ +package com.yape.services.transaction.application.usecase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UpdateTransactionStatusUseCaseTest { + + @Mock + private TransactionRepository transactionRepository; + @Mock + private TransactionStatusRepository transactionStatusRepository; + @Mock + private TransactionCacheService transactionCacheService; + + private UpdateTransactionStatusUseCase useCase; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final int APPROVED_STATUS_ID = 2; + + @BeforeEach + void setUp() { + useCase = new UpdateTransactionStatusUseCase( + transactionRepository, + transactionStatusRepository, + transactionCacheService + ); + } + + @Test + @DisplayName("should update transaction status in database and cache") + void shouldUpdateTransactionStatusSuccessfully() { + // Arrange + TransactionStatusUpdatedEvent event = createEvent( + com.yape.services.transaction.events.enums.TransactionStatus.APPROVED + ); + TransactionStatus approvedStatus = createApprovedStatus(); + + when(transactionStatusRepository.findByCode("APPROVED")) + .thenReturn(Optional.of(approvedStatus)); + when(transactionRepository.updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID)) + .thenReturn(1); + + // Act + useCase.execute(event); + + // Assert + verify(transactionRepository).updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID); + verify(transactionCacheService).updateTransactionStatus( + TRANSACTION_EXTERNAL_ID, + APPROVED_STATUS_ID, + "APPROVED" + ); + } + + @Test + @DisplayName("should update to REJECTED status successfully") + void shouldUpdateToRejectedStatusSuccessfully() { + // Arrange + TransactionStatusUpdatedEvent event = createEvent( + com.yape.services.transaction.events.enums.TransactionStatus.REJECTED + ); + TransactionStatus rejectedStatus = TransactionStatus.builder() + .transactionStatusId(3) + .code("REJECTED") + .name("Rejected") + .build(); + + when(transactionStatusRepository.findByCode("REJECTED")) + .thenReturn(Optional.of(rejectedStatus)); + when(transactionRepository.updateStatus(TRANSACTION_EXTERNAL_ID, 3)) + .thenReturn(1); + + // Act + useCase.execute(event); + + // Assert + verify(transactionRepository).updateStatus(TRANSACTION_EXTERNAL_ID, 3); + verify(transactionCacheService).updateTransactionStatus( + TRANSACTION_EXTERNAL_ID, + 3, + "REJECTED" + ); + } + + @Test + @DisplayName("should throw IllegalStateException when status not found") + void shouldThrowWhenStatusNotFound() { + // Arrange + TransactionStatusUpdatedEvent event = createEvent( + com.yape.services.transaction.events.enums.TransactionStatus.APPROVED + ); + + when(transactionStatusRepository.findByCode("APPROVED")) + .thenReturn(Optional.empty()); + + // Act / Assert + IllegalStateException thrownException = null; + try { + useCase.execute(event); + } catch (IllegalStateException e) { + thrownException = e; + } + + assertNotNull(thrownException); + var expectedMessage = "Transaction status not found: APPROVED"; + assertEquals(expectedMessage, thrownException.getMessage()); + + verify(transactionRepository, never()) + .updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID); + verify(transactionCacheService, never()).updateTransactionStatus( + TRANSACTION_EXTERNAL_ID, + APPROVED_STATUS_ID, + "APPROVED" + ); + } + + @Test + @DisplayName("should not update cache when no rows updated in database") + void shouldNotUpdateCacheWhenNoRowsUpdated() { + // Arrange + TransactionStatusUpdatedEvent event = createEvent( + com.yape.services.transaction.events.enums.TransactionStatus.APPROVED + ); + TransactionStatus approvedStatus = createApprovedStatus(); + + when(transactionStatusRepository.findByCode("APPROVED")) + .thenReturn(Optional.of(approvedStatus)); + when(transactionRepository.updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID)) + .thenReturn(0); + + // Act + useCase.execute(event); + + // Assert + verify(transactionRepository).updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID); + verify(transactionCacheService, never()).updateTransactionStatus( + TRANSACTION_EXTERNAL_ID, + APPROVED_STATUS_ID, + "APPROVED" + ); + } + + private TransactionStatusUpdatedEvent createEvent( + com.yape.services.transaction.events.enums.TransactionStatus newStatus) { + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_STATUS_UPDATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-anti-fraud") + .setVersion("1.0.0") + .setRequestId("request-123") + .build(); + + TransactionStatusUpdatedPayload payload = TransactionStatusUpdatedPayload.newBuilder() + .setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()) + .setPreviousStatus(com.yape.services.transaction.events.enums.TransactionStatus.PENDING) + .setNewStatus(newStatus) + .setValue("100.00") + .setValidationResult( + com.yape.services.transaction.events.ValidationResult.newBuilder() + .setIsValid(true) + .setRuleCode(null) + .build() + ) + .setProcessedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionStatusUpdatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } + + private TransactionStatus createApprovedStatus() { + return TransactionStatus.builder() + .transactionStatusId(APPROVED_STATUS_ID) + .code("APPROVED") + .name("Approved") + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImplTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImplTest.java new file mode 100644 index 0000000000..740a431eef --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImplTest.java @@ -0,0 +1,193 @@ +package com.yape.services.transaction.infrastructure.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.infrastructure.config.TransactionCacheConfig; +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RMapCache; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.Codec; + +@ExtendWith(MockitoExtension.class) +class TransactionCacheServiceImplTest { + + @Mock + private RedissonClient redissonClient; + @Mock + private TransactionCacheConfig cacheConfig; + @Mock + private TransactionCacheConfig.Ttl ttlConfig; + @Mock + private RMapCache mapCache; + + private TransactionCacheServiceImpl cacheService; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final String MAP_NAME = "transactions"; + private static final String PREFIX = "transaction:"; + private static final long PENDING_TTL = 300L; + private static final long APPROVED_TTL = 3600L; + private static final long REJECTED_TTL = 3600L; + + @BeforeEach + void setUp() { + lenient().when(cacheConfig.mapName()).thenReturn(MAP_NAME); + lenient().when(cacheConfig.prefix()).thenReturn(PREFIX); + lenient().when(cacheConfig.ttl()).thenReturn(ttlConfig); + lenient().doReturn(mapCache).when(redissonClient).getMapCache(anyString(), any(Codec.class)); + + cacheService = new TransactionCacheServiceImpl(redissonClient, cacheConfig); + } + + @Test + @DisplayName("should save transaction with PENDING TTL") + void shouldSaveTransactionWithPendingTtl() { + // Arrange + Transaction transaction = createTransaction(); + when(ttlConfig.pending()).thenReturn(PENDING_TTL); + + // Act + cacheService.saveTransaction(transaction, "PENDING"); + + // Assert + String expectedKey = PREFIX + TRANSACTION_EXTERNAL_ID; + verify(mapCache).put(expectedKey, transaction, PENDING_TTL, TimeUnit.SECONDS); + } + + @Test + @DisplayName("should save transaction with APPROVED TTL") + void shouldSaveTransactionWithApprovedTtl() { + // Arrange + Transaction transaction = createTransaction(); + when(ttlConfig.approved()).thenReturn(APPROVED_TTL); + + // Act + cacheService.saveTransaction(transaction, "APPROVED"); + + // Assert + String expectedKey = PREFIX + TRANSACTION_EXTERNAL_ID; + verify(mapCache).put(expectedKey, transaction, APPROVED_TTL, TimeUnit.SECONDS); + } + + @Test + @DisplayName("should save transaction with REJECTED TTL") + void shouldSaveTransactionWithRejectedTtl() { + // Arrange + Transaction transaction = createTransaction(); + when(ttlConfig.rejected()).thenReturn(REJECTED_TTL); + + // Act + cacheService.saveTransaction(transaction, "REJECTED"); + + // Assert + String expectedKey = PREFIX + TRANSACTION_EXTERNAL_ID; + verify(mapCache).put(expectedKey, transaction, REJECTED_TTL, TimeUnit.SECONDS); + } + + @Test + @DisplayName("should throw exception for unknown status") + void shouldThrowExceptionForUnknownStatus() { + // Arrange + Transaction transaction = createTransaction(); + + // Act/Then + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> cacheService.saveTransaction(transaction, "UNKNOWN") + ); + assertTrue(thrown.getMessage().contains("Unexpected value")); + } + + @Test + @DisplayName("should return transaction when found in cache") + void shouldReturnTransactionWhenFoundInCache() { + // Arrange + Transaction transaction = createTransaction(); + String key = PREFIX + TRANSACTION_EXTERNAL_ID; + when(mapCache.get(key)).thenReturn(transaction); + + // Act + Optional result = cacheService + .getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(transaction, result.get()); + } + + @Test + @DisplayName("should return empty when not found in cache") + void shouldReturnEmptyWhenNotFoundInCache() { + // Arrange + String key = PREFIX + TRANSACTION_EXTERNAL_ID; + when(mapCache.get(key)).thenReturn(null); + + // Act + Optional result = cacheService + .getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should update status and TTL when transaction exists in cache") + void shouldUpdateStatusAndTtlWhenTransactionExists() { + // Arrange + Transaction transaction = createTransaction(); + String key = PREFIX + TRANSACTION_EXTERNAL_ID; + when(mapCache.get(key)).thenReturn(transaction); + when(ttlConfig.approved()).thenReturn(APPROVED_TTL); + + // Act + cacheService.updateTransactionStatus(TRANSACTION_EXTERNAL_ID, 2, "APPROVED"); + + // Assert + assertEquals(2, transaction.getTransactionStatusId()); + verify(mapCache).put(key, transaction, APPROVED_TTL, TimeUnit.SECONDS); + } + + @Test + @DisplayName("should not update when transaction not in cache") + void shouldNotUpdateWhenTransactionNotInCache() { + // Arrange + String key = PREFIX + TRANSACTION_EXTERNAL_ID; + when(mapCache.get(key)).thenReturn(null); + + // Act + cacheService.updateTransactionStatus(TRANSACTION_EXTERNAL_ID, 2, "APPROVED"); + + // Assert + verify(mapCache, never()).put(anyString(), any(), anyLong(), any()); + } + + private Transaction createTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .transferTypeId(1) + .transactionStatusId(1) + .value(new BigDecimal("100.00")) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImplTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImplTest.java new file mode 100644 index 0000000000..687319841c --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImplTest.java @@ -0,0 +1,201 @@ +package com.yape.services.transaction.infrastructure.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.infrastructure.config.TransferTypeCacheConfig; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RMapCache; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.Codec; + +@ExtendWith(MockitoExtension.class) +class TransferTypeCacheServiceImplTest { + + @Mock + private RedissonClient redissonClient; + @Mock + private TransferTypeCacheConfig cacheConfig; + @Mock + private RMapCache mapCache; + + private TransferTypeCacheServiceImpl cacheService; + + private static final String MAP_NAME = "transfer-types"; + private static final String PREFIX = "transfer_type:"; + private static final long TTL = 86400L; + + @BeforeEach + void setUp() { + lenient().when(cacheConfig.mapName()).thenReturn(MAP_NAME); + lenient().when(cacheConfig.prefix()).thenReturn(PREFIX); + lenient().doReturn(mapCache).when(redissonClient).getMapCache(anyString(), any(Codec.class)); + + cacheService = new TransferTypeCacheServiceImpl(redissonClient, cacheConfig); + } + + @Nested + @DisplayName("save") + class SaveTests { + + @Test + @DisplayName("should save transfer type with correct TTL") + void shouldSaveTransferTypeWithCorrectTtl() { + // Arrange + TransferType transferType = createTransferType(1); + when(cacheConfig.ttl()).thenReturn(TTL); + + // Act + cacheService.save(transferType); + + // Assert + String expectedKey = PREFIX + "1"; + verify(mapCache).put(expectedKey, transferType, TTL, TimeUnit.SECONDS); + } + } + + @Nested + @DisplayName("saveAll") + class SaveAllTests { + + @Test + @DisplayName("should save all transfer types") + void shouldSaveAllTransferTypes() { + // Arrange + List transferTypes = List.of( + createTransferType(1), + createTransferType(2), + createTransferType(3) + ); + when(cacheConfig.ttl()).thenReturn(TTL); + + // Act + cacheService.saveAll(transferTypes); + + // Assert + verify(mapCache, times(3)).put(any(), any(), eq(TTL), eq(TimeUnit.SECONDS)); + } + + @Test + @DisplayName("should handle empty list") + void shouldHandleEmptyList() { + // Arrange + List transferTypes = List.of(); + + // Act + cacheService.saveAll(transferTypes); + + // Assert + verify(mapCache, times(0)).put(any(), any(), anyLong(), any()); + } + } + + @Test + @DisplayName("should return transfer type when found in cache") + void shouldReturnTransferTypeWhenFoundInCache() { + // Arrange + TransferType transferType = createTransferType(1); + String key = PREFIX + "1"; + when(mapCache.get(key)).thenReturn(transferType); + + // Act + Optional result = cacheService.findById(1); + + // Assert + assertTrue(result.isPresent()); + assertEquals(transferType, result.get()); + } + + @Test + @DisplayName("should return empty when not found in cache") + void shouldReturnEmptyWhenNotFoundInCache() { + // Arrange + String key = PREFIX + "1"; + when(mapCache.get(key)).thenReturn(null); + + // Act + Optional result = cacheService.findById(1); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should return all transfer types when cache is not empty") + void shouldReturnAllTransferTypesWhenCacheNotEmpty() { + // Arrange + TransferType type1 = createTransferType(1); + TransferType type2 = createTransferType(2); + Collection values = List.of(type1, type2); + + when(mapCache.isEmpty()).thenReturn(false); + when(mapCache.values()).thenReturn(values); + + // Act + Optional> result = cacheService.findAll(); + + // Assert + assertTrue(result.isPresent()); + assertEquals(2, result.get().size()); + } + + @Test + @DisplayName("should return empty when cache is empty") + void shouldReturnEmptyWhenCacheIsEmpty() { + // Arrange + when(mapCache.isEmpty()).thenReturn(true); + + // Act + Optional> result = cacheService.findAll(); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should deduplicate transfer types by ID") + void shouldDeduplicateTransferTypesById() { + // Arrange + TransferType type1 = createTransferType(1); + TransferType type1Duplicate = createTransferType(1); + TransferType type2 = createTransferType(2); + Collection values = List.of(type1, type1Duplicate, type2); + + when(mapCache.isEmpty()).thenReturn(false); + when(mapCache.values()).thenReturn(values); + + // Act + Optional> result = cacheService.findAll(); + + // Assert + assertTrue(result.isPresent()); + assertEquals(2, result.get().size()); + } + + private TransferType createTransferType(int id) { + return TransferType.builder() + .transferTypeId(id) + .code("TYPE_" + id) + .name("Transfer Type " + id) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisherTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisherTest.java new file mode 100644 index 0000000000..cc5c435e7e --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisherTest.java @@ -0,0 +1,126 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.events.TransactionCreatedPayload; +import com.yape.services.transaction.events.enums.TransactionStatus; +import io.smallrye.reactive.messaging.kafka.Record; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class KafkaTransactionEventPublisherTest { + + @Mock + private Emitter> emitter; + + private KafkaTransactionEventPublisher publisher; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + publisher = new KafkaTransactionEventPublisher(emitter); + } + + @Test + @DisplayName("should send event to Kafka with transaction external ID as key") + void shouldSendEventToKafkaWithCorrectKey() { + // Arrange + TransactionCreatedEvent event = createEvent(); + CompletableFuture future = CompletableFuture.completedFuture(null); + + when(emitter.send(argThat((Record eventRecord) -> + eventRecord != null && eventRecord.key().equals(TRANSACTION_EXTERNAL_ID.toString()) + ))).thenReturn(future); + + // Act + publisher.publishTransactionCreated(event); + + // Assert + verify(emitter).send(argThat((Record eventRecord) -> + eventRecord.key().equals(TRANSACTION_EXTERNAL_ID.toString()) + && eventRecord.value().equals(event) + )); + } + + @Test + @DisplayName("should send event with correct payload") + void shouldSendEventWithCorrectPayload() { + // Arrange + TransactionCreatedEvent event = createEvent(); + CompletableFuture future = CompletableFuture.completedFuture(null); + + when(emitter.send((Record) argThat(Objects::nonNull))) + .thenReturn(future); + + // Act + publisher.publishTransactionCreated(event); + + // Assert + verify(emitter).send(argThat((Record eventRecord) -> { + TransactionCreatedEvent sentEvent = eventRecord.value(); + assertEquals(TRANSACTION_EXTERNAL_ID.toString(), + sentEvent.getPayload().getTransactionExternalId()); + assertEquals(TransactionStatus.PENDING, sentEvent.getPayload().getStatus()); + return true; + })); + } + + @Test + @DisplayName("should handle failed send gracefully") + void shouldHandleFailedSendGracefully() { + // Arrange + TransactionCreatedEvent event = createEvent(); + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Kafka unavailable")); + + when(emitter.send((Record) argThat(Objects::nonNull))) + .thenReturn(failedFuture); + + // Act + publisher.publishTransactionCreated(event); + + // Assert + verify(emitter).send((Record) argThat(Objects::nonNull)); + } + + private TransactionCreatedEvent createEvent() { + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_CREATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-transaction") + .setVersion("1.0.0") + .setRequestId("request-123") + .build(); + + TransactionCreatedPayload payload = TransactionCreatedPayload.newBuilder() + .setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()) + .setAccountExternalIdDebit(UUID.randomUUID().toString()) + .setAccountExternalIdCredit(UUID.randomUUID().toString()) + .setTransferTypeId(1) + .setValue("100.00") + .setStatus(TransactionStatus.PENDING) + .setCreatedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionCreatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumerTest.java new file mode 100644 index 0000000000..a9cc2dc8fa --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumerTest.java @@ -0,0 +1,156 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.application.usecase.UpdateTransactionStatusUseCase; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import com.yape.services.transaction.events.ValidationResult; +import com.yape.services.transaction.events.enums.TransactionStatus; +import io.smallrye.reactive.messaging.kafka.Record; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class KafkaTransactionStatusConsumerTest { + + @Mock + private UpdateTransactionStatusUseCase updateTransactionStatusUseCase; + + private KafkaTransactionStatusConsumer consumer; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final String MESSAGE_KEY = TRANSACTION_EXTERNAL_ID.toString(); + + @BeforeEach + void setUp() { + consumer = new KafkaTransactionStatusConsumer(updateTransactionStatusUseCase); + } + + @Test + @DisplayName("should delegate to use case with event from record") + void shouldDelegateToUseCaseWithEventFromRecord() { + // Given + TransactionStatusUpdatedEvent event = createEvent(TransactionStatus.APPROVED); + Record eventRecord = Record.of(MESSAGE_KEY, event); + + // When + consumer.consume(eventRecord); + + // Then + verify(updateTransactionStatusUseCase).execute(event); + } + + @Test + @DisplayName("should handle REJECTED status events") + void shouldHandleRejectedStatusEvents() { + // Given + TransactionStatusUpdatedEvent event = createEvent(TransactionStatus.REJECTED); + Record eventRecord = Record.of(MESSAGE_KEY, event); + + // When + consumer.consume(eventRecord); + + // Then + verify(updateTransactionStatusUseCase).execute(event); + } + + @Test + @DisplayName("should rethrow exception when use case fails") + void shouldRethrowExceptionWhenUseCaseFails() { + // Given + TransactionStatusUpdatedEvent event = createEvent(TransactionStatus.APPROVED); + Record eventRecord = Record.of(MESSAGE_KEY, event); + RuntimeException expectedException = new RuntimeException("Database error"); + + doThrow(expectedException).when(updateTransactionStatusUseCase).execute(event); + + // When/Then + RuntimeException exception = + assertThrows(RuntimeException.class, () -> consumer.consume(eventRecord)); + assertEquals("Database error", exception.getMessage()); + } + + @Test + @DisplayName("should process event with null request ID in metadata") + void shouldProcessEventWithNullRequestId() { + // Given + TransactionStatusUpdatedEvent event = createEventWithNullRequestId(); + Record eventRecord = Record.of(MESSAGE_KEY, event); + + // When + consumer.consume(eventRecord); + + // Then + verify(updateTransactionStatusUseCase).execute(event); + } + + private TransactionStatusUpdatedEvent createEvent(TransactionStatus newStatus) { + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_STATUS_UPDATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-anti-fraud") + .setVersion("1.0.0") + .setRequestId("request-123") + .build(); + + ValidationResult validationResult = ValidationResult.newBuilder() + .setIsValid(newStatus == TransactionStatus.APPROVED) + .setRuleCode(newStatus == TransactionStatus.REJECTED ? "MAX_AMOUNT_EXCEEDED" : null) + .build(); + + TransactionStatusUpdatedPayload payload = TransactionStatusUpdatedPayload.newBuilder() + .setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()) + .setPreviousStatus(TransactionStatus.PENDING) + .setNewStatus(newStatus) + .setValue("100.00") + .setValidationResult(validationResult) + .setProcessedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionStatusUpdatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } + + private TransactionStatusUpdatedEvent createEventWithNullRequestId() { + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_STATUS_UPDATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-anti-fraud") + .setVersion("1.0.0") + .setRequestId(null) + .build(); + + ValidationResult validationResult = ValidationResult.newBuilder() + .setIsValid(true) + .setRuleCode(null) + .build(); + + TransactionStatusUpdatedPayload payload = TransactionStatusUpdatedPayload.newBuilder() + .setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()) + .setPreviousStatus(TransactionStatus.PENDING) + .setNewStatus(TransactionStatus.APPROVED) + .setValue("100.00") + .setValidationResult(validationResult) + .setProcessedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionStatusUpdatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistenceTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistenceTest.java new file mode 100644 index 0000000000..856bc9529b --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistenceTest.java @@ -0,0 +1,190 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransactionPostgresRepository; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionPersistenceTest { + + @Mock + private TransactionPostgresRepository repository; + + @Captor + private ArgumentCaptor entityCaptor; + + private TransactionPersistence persistence; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final BigDecimal VALUE = new BigDecimal("500.00"); + + @BeforeEach + void setUp() { + persistence = new TransactionPersistence(repository); + } + + @Test + @DisplayName("should convert domain to entity and save") + void shouldConvertDomainToEntityAndSave() { + // Arrange + TransactionEntity savedEntity = createEntity(); + when(repository.save(any(TransactionEntity.class))).thenReturn(savedEntity); + + // Act + Transaction transaction = createDomainTransaction(); + persistence.save(transaction); + + // Assert + verify(repository).save(entityCaptor.capture()); + TransactionEntity capturedEntity = entityCaptor.getValue(); + + assertEquals(TRANSACTION_EXTERNAL_ID, capturedEntity.getTransactionExternalId()); + assertEquals(DEBIT_ACCOUNT_ID, capturedEntity.getAccountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID, capturedEntity.getAccountExternalIdCredit()); + assertEquals(1, capturedEntity.getTransferTypeId()); + assertEquals(1, capturedEntity.getTransactionStatusId()); + assertEquals(0, capturedEntity.getValue().compareTo(VALUE)); + } + + @Test + @DisplayName("should return domain object from saved entity") + void shouldReturnDomainObjectFromSavedEntity() { + // Arrange + Transaction transaction = createDomainTransaction(); + TransactionEntity savedEntity = createEntity(); + when(repository.save(any(TransactionEntity.class))).thenReturn(savedEntity); + + // Act + Transaction result = persistence.save(transaction); + + // Assert + assertEquals(TRANSACTION_EXTERNAL_ID, result.getTransactionExternalId()); + assertEquals(0, result.getValue().compareTo(VALUE)); + } + + @Test + @DisplayName("should return transaction when found") + void shouldReturnTransactionWhenFound() { + // Arrange + TransactionEntity entity = createEntity(); + when(repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(entity); + + // Act + Optional result = persistence.findByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(TRANSACTION_EXTERNAL_ID, result.get().getTransactionExternalId()); + assertEquals(0, result.get().getValue().compareTo(VALUE)); + } + + @Test + @DisplayName("should return empty when not found") + void shouldReturnEmptyWhenNotFound() { + // Arrange + when(repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(null); + + // Act + Optional result = persistence.findByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should map all entity fields to domain") + void shouldMapAllEntityFieldsToDomain() { + // Arrange + TransactionEntity entity = createEntity(); + entity.setCreatedAt(LocalDateTime.of(2024, 1, 15, 10, 30)); + when(repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(entity); + + // Act + Optional result = persistence.findByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + Transaction tx = result.get(); + assertEquals(TRANSACTION_EXTERNAL_ID, tx.getTransactionExternalId()); + assertEquals(DEBIT_ACCOUNT_ID, tx.getAccountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID, tx.getAccountExternalIdCredit()); + assertEquals(1, tx.getTransferTypeId()); + assertEquals(1, tx.getTransactionStatusId()); + assertEquals(0, tx.getValue().compareTo(VALUE)); + assertEquals(LocalDateTime.of(2024, 1, 15, 10, 30), tx.getCreatedAt()); + } + + @Test + @DisplayName("should delegate to repository and return updated count") + void shouldDelegateToRepositoryAndReturnUpdatedCount() { + // Arrange + when(repository.updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, 2)) + .thenReturn(1); + + // Act + int result = persistence.updateStatus(TRANSACTION_EXTERNAL_ID, 2); + + // Assert + assertEquals(1, result); + verify(repository).updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, 2); + } + + @Test + @DisplayName("should return zero when no rows updated") + void shouldReturnZeroWhenNoRowsUpdated() { + // Arrange + when(repository.updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, 2)) + .thenReturn(0); + + // Act + int result = persistence.updateStatus(TRANSACTION_EXTERNAL_ID, 2); + + // Assert + assertEquals(0, result); + } + + private Transaction createDomainTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(1) + .transactionStatusId(1) + .value(VALUE) + .build(); + } + + private TransactionEntity createEntity() { + TransactionEntity entity = new TransactionEntity(); + entity.setTransactionExternalId(TRANSACTION_EXTERNAL_ID); + entity.setAccountExternalIdDebit(DEBIT_ACCOUNT_ID); + entity.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID); + entity.setTransferTypeId(1); + entity.setTransactionStatusId(1); + entity.setValue(VALUE); + return entity; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistenceTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistenceTest.java new file mode 100644 index 0000000000..4c0ed0492a --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistenceTest.java @@ -0,0 +1,144 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionStatusEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransactionStatusPostgresRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionStatusPersistenceTest { + + @Mock + private TransactionStatusPostgresRepository repository; + + private TransactionStatusPersistence persistence; + + private static final Integer STATUS_ID = 1; + private static final String STATUS_CODE = "PENDING"; + private static final String STATUS_NAME = "Pending"; + + @BeforeEach + void setUp() { + persistence = new TransactionStatusPersistence(repository); + } + + @Test + @DisplayName("should return transaction status when found by code") + void shouldReturnTransactionStatusWhenFoundByCode() { + // Arrange + TransactionStatusEntity entity = createEntity(); + when(repository.findByCode(STATUS_CODE)).thenReturn(entity); + + // Act + Optional result = persistence.findByCode(STATUS_CODE); + + // Assert + assertTrue(result.isPresent()); + assertEquals(STATUS_ID, result.get().getTransactionStatusId()); + assertEquals(STATUS_CODE, result.get().getCode()); + assertEquals(STATUS_NAME, result.get().getName()); + verify(repository).findByCode(STATUS_CODE); + } + + @Test + @DisplayName("should return empty when not found by code") + void shouldReturnEmptyWhenNotFoundByCode() { + // Arrange + when(repository.findByCode(STATUS_CODE)).thenReturn(null); + + // Act + Optional result = persistence.findByCode(STATUS_CODE); + + // Assert + assertTrue(result.isEmpty()); + verify(repository).findByCode(STATUS_CODE); + } + + @Test + @DisplayName("should handle different status codes") + void shouldHandleDifferentStatusCodes() { + // Arrange + String approvedCode = "APPROVED"; + TransactionStatusEntity entity = new TransactionStatusEntity(); + entity.setTransactionStatusId(2); + entity.setCode(approvedCode); + entity.setName("Approved"); + when(repository.findByCode(approvedCode)).thenReturn(entity); + + // Act + Optional result = persistence.findByCode(approvedCode); + + // Assert + assertTrue(result.isPresent()); + assertEquals(approvedCode, result.get().getCode()); + } + + @Test + @DisplayName("should return transaction status when found by ID") + void shouldReturnTransactionStatusWhenFoundById() { + // Arrange + TransactionStatusEntity entity = createEntity(); + when(repository.findById(STATUS_ID)).thenReturn(entity); + + // Act + Optional result = persistence.findById(STATUS_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(STATUS_ID, result.get().getTransactionStatusId()); + assertEquals(STATUS_CODE, result.get().getCode()); + assertEquals(STATUS_NAME, result.get().getName()); + verify(repository).findById(STATUS_ID); + } + + @Test + @DisplayName("should return empty when not found by ID") + void shouldReturnEmptyWhenNotFoundById() { + // Arrange + when(repository.findById(STATUS_ID)).thenReturn(null); + + // Act + Optional result = persistence.findById(STATUS_ID); + + // Assert + assertTrue(result.isEmpty()); + verify(repository).findById(STATUS_ID); + } + + @Test + @DisplayName("should map all entity fields to domain") + void shouldMapAllEntityFieldsToDomain() { + // Arrange + TransactionStatusEntity entity = createEntity(); + when(repository.findById(STATUS_ID)).thenReturn(entity); + + // Act + Optional result = persistence.findById(STATUS_ID); + + // Assert + assertTrue(result.isPresent()); + TransactionStatus status = result.get(); + assertEquals(entity.getTransactionStatusId(), status.getTransactionStatusId()); + assertEquals(entity.getCode(), status.getCode()); + assertEquals(entity.getName(), status.getName()); + } + + private TransactionStatusEntity createEntity() { + TransactionStatusEntity entity = new TransactionStatusEntity(); + entity.setTransactionStatusId(STATUS_ID); + entity.setCode(STATUS_CODE); + entity.setName(STATUS_NAME); + return entity; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistenceTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistenceTest.java new file mode 100644 index 0000000000..c16b314a5d --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistenceTest.java @@ -0,0 +1,178 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.infrastructure.persistence.entity.TransferTypeEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransferTypePostgresRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransferTypePersistenceTest { + + @Mock + private TransferTypePostgresRepository repository; + + private TransferTypePersistence persistence; + + private static final Integer TRANSFER_TYPE_ID = 1; + private static final String TRANSFER_TYPE_CODE = "INTERNAL"; + private static final String TRANSFER_TYPE_NAME = "Internal Transfer"; + + @BeforeEach + void setUp() { + persistence = new TransferTypePersistence(repository); + } + + @Test + @DisplayName("should return transfer type when found by ID") + void shouldReturnTransferTypeWhenFoundById() { + // Given + TransferTypeEntity entity = createEntity(); + when(repository.findByTransferTypeId(TRANSFER_TYPE_ID)).thenReturn(entity); + + // When + Optional result = persistence.findById(TRANSFER_TYPE_ID); + + // Then + assertTrue(result.isPresent()); + assertEquals(TRANSFER_TYPE_ID, result.get().getTransferTypeId()); + assertEquals(TRANSFER_TYPE_CODE, result.get().getCode()); + assertEquals(TRANSFER_TYPE_NAME, result.get().getName()); + verify(repository).findByTransferTypeId(TRANSFER_TYPE_ID); + } + + @Test + @DisplayName("should return empty when not found by ID") + void shouldReturnEmptyWhenNotFoundById() { + // Given + when(repository.findByTransferTypeId(TRANSFER_TYPE_ID)).thenReturn(null); + + // When + Optional result = persistence.findById(TRANSFER_TYPE_ID); + + // Then + assertTrue(result.isEmpty()); + verify(repository).findByTransferTypeId(TRANSFER_TYPE_ID); + } + + @Test + @DisplayName("should map all entity fields to domain") + void shouldMapAllEntityFieldsToDomain() { + // Given + TransferTypeEntity entity = createEntity(); + when(repository.findByTransferTypeId(TRANSFER_TYPE_ID)).thenReturn(entity); + + // When + Optional result = persistence.findById(TRANSFER_TYPE_ID); + + // Then + assertTrue(result.isPresent()); + TransferType type = result.get(); + assertEquals(entity.getTransferTypeId(), type.getTransferTypeId()); + assertEquals(entity.getCode(), type.getCode()); + assertEquals(entity.getName(), type.getName()); + } + + @Test + @DisplayName("should return all transfer types") + void shouldReturnAllTransferTypes() { + // Given + List entities = List.of( + createEntity(1, "INTERNAL", "Internal Transfer"), + createEntity(2, "EXTERNAL", "External Transfer"), + createEntity(3, "INTERBANK", "Interbank Transfer") + ); + when(repository.findAllTransferTypes()).thenReturn(entities); + + // When + List result = persistence.findAll(); + + // Then + assertEquals(3, result.size()); + assertEquals("INTERNAL", result.get(0).getCode()); + assertEquals("EXTERNAL", result.get(1).getCode()); + assertEquals("INTERBANK", result.get(2).getCode()); + verify(repository).findAllTransferTypes(); + } + + @Test + @DisplayName("should return empty list when no transfer types exist") + void shouldReturnEmptyListWhenNoTransferTypesExist() { + // Given + when(repository.findAllTransferTypes()).thenReturn(List.of()); + + // When + List result = persistence.findAll(); + + // Then + assertTrue(result.isEmpty()); + verify(repository).findAllTransferTypes(); + } + + @Test + @DisplayName("should map all entities to domain objects") + void shouldMapAllEntitiesToDomainObjects() { + // Given + List entities = List.of( + createEntity(1, "INTERNAL", "Internal Transfer"), + createEntity(2, "EXTERNAL", "External Transfer") + ); + when(repository.findAllTransferTypes()).thenReturn(entities); + + // When + List result = persistence.findAll(); + + // Then + assertEquals(2, result.size()); + result.forEach(type -> { + assertNotNull(type.getTransferTypeId()); + assertNotNull(type.getCode()); + assertNotNull(type.getName()); + }); + } + + @Test + @DisplayName("should preserve order of transfer types") + void shouldPreserveOrderOfTransferTypes() { + // Given + List entities = List.of( + createEntity(3, "THIRD", "Third"), + createEntity(1, "FIRST", "First"), + createEntity(2, "SECOND", "Second") + ); + when(repository.findAllTransferTypes()).thenReturn(entities); + + // When + List result = persistence.findAll(); + + // Then + assertEquals(3, result.size()); + assertEquals(3, result.get(0).getTransferTypeId()); + assertEquals(1, result.get(1).getTransferTypeId()); + assertEquals(2, result.get(2).getTransferTypeId()); + } + + private TransferTypeEntity createEntity() { + return createEntity(TRANSFER_TYPE_ID, TRANSFER_TYPE_CODE, TRANSFER_TYPE_NAME); + } + + private TransferTypeEntity createEntity(Integer id, String code, String name) { + TransferTypeEntity entity = new TransferTypeEntity(); + entity.setTransferTypeId(id); + entity.setCode(code); + entity.setName(name); + return entity; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepositoryTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepositoryTest.java new file mode 100644 index 0000000000..925e78314f --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepositoryTest.java @@ -0,0 +1,200 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionEntity; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionPostgresRepositoryTest { + + private TransactionPostgresRepository repository; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final BigDecimal VALUE = new BigDecimal("1000.00"); + + @BeforeEach + void setUp() { + repository = spy(new TransactionPostgresRepository()); + } + + @Test + @DisplayName("should return entity when found by external ID") + @SuppressWarnings("unchecked") + void shouldReturnEntityWhenFoundByExternalId() { + // Arrange + TransactionEntity entity = createEntity(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository) + .find("transactionExternalId", TRANSACTION_EXTERNAL_ID); + doReturn(entity).when(query).firstResult(); + + // Act + TransactionEntity result = repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertNotNull(result); + assertEquals(TRANSACTION_EXTERNAL_ID, result.getTransactionExternalId()); + verify(repository).find("transactionExternalId", TRANSACTION_EXTERNAL_ID); + } + + @Test + @DisplayName("should return null when not found by external ID") + @SuppressWarnings("unchecked") + void shouldReturnNullWhenNotFoundByExternalId() { + // Arrange + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository) + .find("transactionExternalId", TRANSACTION_EXTERNAL_ID); + doReturn(null).when(query).firstResult(); + + // Act + TransactionEntity result = repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("should use correct field name for query") + @SuppressWarnings("unchecked") + void shouldUseCorrectFieldNameForQuery() { + // Arrange + UUID specificId = UUID.randomUUID(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionExternalId", specificId); + doReturn(null).when(query).firstResult(); + + // Act + repository.findByTransactionExternalId(specificId); + + // Assert + verify(repository).find("transactionExternalId", specificId); + } + + @Test + @DisplayName("should persist entity and return it") + void shouldPersistEntityAndReturnIt() { + // Arrange + TransactionEntity entity = createEntity(); + doAnswer(invocation -> null).when(repository).persist(any(TransactionEntity.class)); + + // Act + TransactionEntity result = repository.save(entity); + + // Assert + assertEquals(entity, result); + verify(repository).persist(entity); + } + + @Test + @DisplayName("should call persist with correct entity") + void shouldCallPersistWithCorrectEntity() { + // Arrange + TransactionEntity entity = createEntity(); + doAnswer(invocation -> null).when(repository).persist(any(TransactionEntity.class)); + + // Act + repository.save(entity); + + // Assert + verify(repository).persist(entity); + } + + @Test + @DisplayName("should preserve all entity fields after save") + void shouldPreserveAllEntityFieldsAfterSave() { + // Arrange + TransactionEntity entity = createEntity(); + entity.setCreatedAt(LocalDateTime.now()); + doAnswer(invocation -> null).when(repository).persist(any(TransactionEntity.class)); + + // Act + TransactionEntity result = repository.save(entity); + + // Assert + assertEquals(entity.getTransactionExternalId(), result.getTransactionExternalId()); + assertEquals(entity.getAccountExternalIdDebit(), result.getAccountExternalIdDebit()); + assertEquals(entity.getAccountExternalIdCredit(), result.getAccountExternalIdCredit()); + assertEquals(entity.getValue(), result.getValue()); + } + + @Test + @DisplayName("should update status and return count") + void shouldUpdateStatusAndReturnCount() { + // Arrange + Integer newStatusId = 2; + doReturn(1).when(repository) + .update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, TRANSACTION_EXTERNAL_ID); + + // Act + int result = repository.updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, newStatusId); + + // Assert + assertEquals(1, result); + } + + @Test + @DisplayName("should return zero when no rows updated") + void shouldReturnZeroWhenNoRowsUpdated() { + // Arrange + Integer newStatusId = 2; + UUID nonExistentId = UUID.randomUUID(); + doReturn(0).when(repository) + .update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, nonExistentId); + + // Act + int result = repository.updateStatusByExternalId(nonExistentId, newStatusId); + + // Assert + assertEquals(0, result); + } + + @Test + @DisplayName("should use correct update query format") + void shouldUseCorrectUpdateQueryFormat() { + // Arrange + Integer newStatusId = 3; + doReturn(1).when(repository) + .update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, TRANSACTION_EXTERNAL_ID); + + // Act + repository.updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, newStatusId); + + // Assert + verify(repository).update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, TRANSACTION_EXTERNAL_ID); + } + + private TransactionEntity createEntity() { + TransactionEntity entity = new TransactionEntity(); + entity.setTransactionExternalId(TRANSACTION_EXTERNAL_ID); + entity.setAccountExternalIdDebit(DEBIT_ACCOUNT_ID); + entity.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID); + entity.setTransferTypeId(1); + entity.setTransactionStatusId(1); + entity.setValue(VALUE); + return entity; + } +} \ No newline at end of file diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepositoryTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepositoryTest.java new file mode 100644 index 0000000000..3b109c8c43 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepositoryTest.java @@ -0,0 +1,184 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionStatusEntity; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TransactionStatusPostgresRepository") +class TransactionStatusPostgresRepositoryTest { + + private TransactionStatusPostgresRepository repository; + + private static final Integer STATUS_ID = 1; + private static final String STATUS_CODE = "PENDING"; + private static final String STATUS_NAME = "Pending"; + + @BeforeEach + void setUp() { + repository = spy(new TransactionStatusPostgresRepository()); + } + + @Test + @DisplayName("should return entity when found by code") + @SuppressWarnings("unchecked") + void shouldReturnEntityWhenFoundByCode() { + // Arrange + TransactionStatusEntity entity = createEntity(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("code", STATUS_CODE); + doReturn(entity).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findByCode(STATUS_CODE); + + // Assert + assertEquals(STATUS_ID, result.getTransactionStatusId()); + assertEquals(STATUS_CODE, result.getCode()); + assertEquals(STATUS_NAME, result.getName()); + } + + @Test + @DisplayName("should return null when not found by code") + @SuppressWarnings("unchecked") + void shouldReturnNullWhenNotFoundByCode() { + // Arrange + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("code", STATUS_CODE); + doReturn(null).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findByCode(STATUS_CODE); + + // Assert + assertEquals(null, result); + } + + @Test + @DisplayName("should use correct field name for query") + @SuppressWarnings("unchecked") + void shouldUseCorrectFieldNameForQuery() { + // Arrange + String specificCode = "APPROVED"; + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("code", specificCode); + doReturn(null).when(query).firstResult(); + + // Act + repository.findByCode(specificCode); + + // Assert + verify(repository).find("code", specificCode); + } + + @Test + @DisplayName("should handle different status codes") + @SuppressWarnings("unchecked") + void shouldHandleDifferentStatusCodes() { + // Arrange + TransactionStatusEntity approvedEntity = createEntity(2, "APPROVED", "Approved"); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("code", "APPROVED"); + doReturn(approvedEntity).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findByCode("APPROVED"); + + // Assert + assertEquals("APPROVED", result.getCode()); + assertEquals("Approved", result.getName()); + } + + @Test + @DisplayName("should return entity when found by ID") + @SuppressWarnings("unchecked") + void shouldReturnEntityWhenFoundById() { + // Arrange + TransactionStatusEntity entity = createEntity(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionStatusId", STATUS_ID); + doReturn(entity).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findById(STATUS_ID); + + // Assert + assertEquals(STATUS_ID, result.getTransactionStatusId()); + assertEquals(STATUS_CODE, result.getCode()); + assertEquals(STATUS_NAME, result.getName()); + } + + @Test + @DisplayName("should return null when not found by ID") + @SuppressWarnings("unchecked") + void shouldReturnNullWhenNotFoundById() { + // Arrange + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionStatusId", STATUS_ID); + doReturn(null).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findById(STATUS_ID); + + // Assert + assertEquals(null, result); + } + + @Test + @DisplayName("should use correct field name for ID query") + @SuppressWarnings("unchecked") + void shouldUseCorrectFieldNameForIdQuery() { + // Arrange + Integer specificId = 3; + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionStatusId", specificId); + doReturn(null).when(query).firstResult(); + + // Act + repository.findById(specificId); + + // Assert + verify(repository).find("transactionStatusId", specificId); + } + + @Test + @DisplayName("should return all entity fields when found") + @SuppressWarnings("unchecked") + void shouldReturnAllEntityFieldsWhenFound() { + // Arrange + TransactionStatusEntity entity = createEntity(3, "REJECTED", "Rejected"); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionStatusId", 3); + doReturn(entity).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findById(3); + + // Assert + assertEquals(3, result.getTransactionStatusId()); + assertEquals("REJECTED", result.getCode()); + assertEquals("Rejected", result.getName()); + } + + private TransactionStatusEntity createEntity() { + return createEntity(STATUS_ID, STATUS_CODE, STATUS_NAME); + } + + private TransactionStatusEntity createEntity(Integer id, String code, String name) { + TransactionStatusEntity entity = new TransactionStatusEntity(); + entity.setTransactionStatusId(id); + entity.setCode(code); + entity.setName(name); + return entity; + } +} \ No newline at end of file diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepositoryTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepositoryTest.java new file mode 100644 index 0000000000..7c9e41be0c --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepositoryTest.java @@ -0,0 +1,183 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransferTypeEntity; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransferTypePostgresRepositoryTest { + + private TransferTypePostgresRepository repository; + + private static final Integer TRANSFER_TYPE_ID = 1; + private static final String TRANSFER_TYPE_CODE = "INTERNAL"; + private static final String TRANSFER_TYPE_NAME = "Internal Transfer"; + + @BeforeEach + void setUp() { + repository = spy(new TransferTypePostgresRepository()); + } + + @Test + @DisplayName("should return entity when found by ID") + @SuppressWarnings("unchecked") + void shouldReturnEntityWhenFoundById() { + // Arrange + TransferTypeEntity entity = createEntity(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transferTypeId", TRANSFER_TYPE_ID); + doReturn(entity).when(query).firstResult(); + + // Act + TransferTypeEntity result = repository.findByTransferTypeId(TRANSFER_TYPE_ID); + + // Assert + assertNotNull(result); + assertEquals(TRANSFER_TYPE_ID, result.getTransferTypeId()); + assertEquals(TRANSFER_TYPE_CODE, result.getCode()); + assertEquals(TRANSFER_TYPE_NAME, result.getName()); + } + + @Test + @DisplayName("should return null when not found by ID") + @SuppressWarnings("unchecked") + void shouldReturnNullWhenNotFoundById() { + // Arrange + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transferTypeId", TRANSFER_TYPE_ID); + doReturn(null).when(query).firstResult(); + + // Act + TransferTypeEntity result = repository.findByTransferTypeId(TRANSFER_TYPE_ID); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("should use correct field name for query") + @SuppressWarnings("unchecked") + void shouldUseCorrectFieldNameForQuery() { + // Arrange + Integer specificId = 99; + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transferTypeId", specificId); + doReturn(null).when(query).firstResult(); + + // Act + repository.findByTransferTypeId(specificId); + + // Assert + verify(repository).find("transferTypeId", specificId); + } + + @Test + @DisplayName("should return entity with all fields mapped") + @SuppressWarnings("unchecked") + void shouldReturnEntityWithAllFieldsMapped() { + // Arrange + TransferTypeEntity entity = createEntity(2, "EXTERNAL", "External Transfer"); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transferTypeId", 2); + doReturn(entity).when(query).firstResult(); + + // Act + TransferTypeEntity result = repository.findByTransferTypeId(2); + + // Assert + assertEquals(2, result.getTransferTypeId()); + assertEquals("EXTERNAL", result.getCode()); + assertEquals("External Transfer", result.getName()); + } + + @Test + @DisplayName("should return all transfer types") + void shouldReturnAllTransferTypes() { + // Arrange + List entities = List.of( + createEntity(1, "INTERNAL", "Internal Transfer"), + createEntity(2, "EXTERNAL", "External Transfer"), + createEntity(3, "INTERBANK", "Interbank Transfer") + ); + doReturn(entities).when(repository).listAll(); + + // Act + List result = repository.findAllTransferTypes(); + + // Assert + assertEquals(3, result.size()); + verify(repository).listAll(); + } + + @Test + @DisplayName("should return empty list when no transfer types exist") + void shouldReturnEmptyListWhenNoTransferTypesExist() { + // Arrange + doReturn(List.of()).when(repository).listAll(); + + // Act + List result = repository.findAllTransferTypes(); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should preserve order of results") + void shouldPreserveOrderOfResults() { + // Arrange + List entities = List.of( + createEntity(3, "THIRD", "Third Type"), + createEntity(1, "FIRST", "First Type"), + createEntity(2, "SECOND", "Second Type") + ); + doReturn(entities).when(repository).listAll(); + + // Act + List result = repository.findAllTransferTypes(); + + // Assert + assertEquals(3, result.get(0).getTransferTypeId()); + assertEquals(1, result.get(1).getTransferTypeId()); + assertEquals(2, result.get(2).getTransferTypeId()); + } + + @Test + @DisplayName("should delegate to listAll method") + void shouldDelegateToListAllMethod() { + // Arrange + doReturn(List.of()).when(repository).listAll(); + + // Act + repository.findAllTransferTypes(); + + // Assert + verify(repository).listAll(); + } + + private TransferTypeEntity createEntity() { + return createEntity(TRANSFER_TYPE_ID, TRANSFER_TYPE_CODE, TRANSFER_TYPE_NAME); + } + + private TransferTypeEntity createEntity(Integer id, String code, String name) { + TransferTypeEntity entity = new TransferTypeEntity(); + entity.setTransferTypeId(id); + entity.setCode(code); + entity.setName(name); + return entity; + } +}