diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b143a7..b93539e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [develop, main] + branches: ["epic/**", develop, main] pull_request: branches: [main] diff --git a/README.md b/README.md index c85bb7e..59b633c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # payment-api -Fundação de um core de pagamentos Pix em Java — infraestrutura, observabilidade e pipeline CI/CD prontos; domínio em construção. +Core de pagamentos Pix em Java — Hexagonal Architecture, observabilidade e pipeline CI/CD. EPIC-001 (criar cobrança) implementado e testado. [![CI](https://github.com/lmoraesdev/java-payment-hexagonal/actions/workflows/ci.yml/badge.svg)](https://github.com/lmoraesdev/java-payment-hexagonal/actions/workflows/ci.yml) ![Java](https://img.shields.io/badge/Java-21-blue?logo=openjdk&logoColor=white) @@ -8,7 +8,12 @@ Fundação de um core de pagamentos Pix em Java — infraestrutura, observabilid ## Status -A plataforma está completa: serviços sobem, métricas chegam no Grafana, traces no Jaeger, testes de integração passam com Testcontainers. O que ainda não existe é o domínio de negócio — entidades, casos de uso, portas e adapters de persistência/mensageria. Esses são os próximos passos (ver [Roadmap](#roadmap)). +| EPIC | Descrição | Status | +|---|---|---| +| EPIC-001 | Criar cobrança — domínio, persistência, REST, tratamento de erros, testes | ✅ Concluído | +| EPIC-002 | Buscar cobrança (`GET /charges/{id}`) + idempotência | 🔲 Roadmap | +| EPIC-003 | Publicar `ChargeCreated` no Kafka | 🔲 Roadmap | +| EPIC-008 | Flyway — migrações de schema versionadas | 🔲 Roadmap | ## Stack @@ -19,9 +24,10 @@ A plataforma está completa: serviços sobem, métricas chegam no Grafana, trace | PostgreSQL | 18 | Persistência principal | | Kafka (KRaft) | 3.9 | Event streaming — sem Zookeeper | | Redis | 7 | Cache / idempotência (pré-instalado, profile `cache`) | +| SpringDoc OpenAPI | 2.8.17 | Swagger UI + spec OpenAPI 3 | | Prometheus + Grafana | latest | Métricas + dashboard Payment Overview pré-provisionado | | Jaeger + OpenTelemetry | latest | Distributed tracing via OTLP HTTP | -| Testcontainers | 1.21 | Testes de integração com banco real | +| Testcontainers | 1.21 | Testes de integração com PostgreSQL 18 real | | Spotless (GJF AOSP) | 2.43 | Formatação automática de código | | Checkstyle | 3.5 | Verificação de estilo | @@ -29,102 +35,132 @@ A plataforma está completa: serviços sobem, métricas chegam no Grafana, trace O projeto segue Arquitetura Hexagonal (Ports & Adapters): o domínio não conhece Spring, JPA nem Kafka. Frameworks e infraestrutura ficam nas bordas; a lógica de negócio fica isolada e testável sem container. -Decisões de projeto: -- **Observabilidade desde o início** — Prometheus, Grafana e Jaeger estão na infra antes do primeiro use case existir. Métricas e traces não são afterthought. -- **Logging estruturado 5W1H** — cada log emite JSON com `where`, `why`, `when`, `who`, `what`, `how` + `traceId`/`spanId` injetados automaticamente pelo Micrometer MDC. Facilita correlação em produção. -- **Event-driven preparado** — Kafka configurado com KRaft (sem Zookeeper), consumer/producer prontos no `application.yml`. Nenhum evento publicado ainda. - ``` com.lmoraesdev.payment ├── adapter -│ ├── in.web ← PingController, GlobalExceptionHandler -│ └── out -│ ├── messaging ← (roadmap — Kafka producers) -│ └── persistence ← (roadmap — JPA repositories) +│ ├── in.web ← ChargeController, GlobalExceptionHandler, DTOs +│ └── out.persistence ← ChargeJpaEntity, ChargeMapper, ChargeRepositoryAdapter ├── application -│ ├── port.in ← (roadmap — interfaces de entrada) -│ ├── port.out ← (roadmap — interfaces de saída) -│ └── usecase ← (roadmap — casos de uso) +│ ├── port.in ← CreateCharge (interface), CreateChargeCommand, CreateChargeResult +│ ├── port.out ← ChargeRepository (interface) +│ └── usecase ← CreateChargeService ├── config -│ └── logging ← Log5w1h, Logger5w1hBuilder +│ ├── logging ← Log5w1h, Logger5w1hBuilder (structured 5W1H logging) +│ └── OpenApiConfig ← SpringDoc / Swagger UI └── domain - ├── event ← (roadmap — domain events) - ├── exception ← DomainException (base abstrata com código de erro) - └── model ← (roadmap — entidades e value objects) + ├── exception ← DomainException (base), InvalidAmountException + └── model ← Charge, Money, ChargeStatus ``` -Especificação do domínio planejado (Charge Pix, máquina de estados, Money, idempotência): [`docs/architecture.md`](docs/architecture.md). +Decisões de projeto: +- **Domínio puro** — `Charge`, `Money`, `ChargeStatus` sem nenhuma anotação de framework +- **Armazenamento monetário em centavos** — `amount_centavos BIGINT` no banco; `Money` normaliza para scale=2 no domínio; o mapper converte nos dois sentidos. Elimina risco de ponto flutuante em operações financeiras. +- **Erros tipados** — `InvalidAmountException extends DomainException` → 422; genéricos → 500 +- **Logging estratégico** — só o use case loga o evento de negócio (`charge_created`); controller e adapters não logam (OTel/Jaeger cobre o fluxo) +- **Problem Details (RFC 9457)** — todos os erros retornam `ProblemDetail` com `traceId` +- **Observabilidade desde o início** — Prometheus, Grafana e Jaeger na infra antes do primeiro use case ## Como rodar -**Pré-requisitos:** Docker Desktop com WSL2 integration habilitada; contexto Docker configurado para `default` (`docker context use default`). +**Pré-requisitos:** Docker Desktop com WSL2 integration habilitada; contexto Docker configurado para `default`. ```bash # 1. Variáveis de ambiente cp .env.example .env -# 2. Subir infra + app (sem Redis) +# 2. Subir infra + app make up -# equivalente: docker compose up -d --build # 3. Subir com Redis (profile cache) docker compose --profile cache up -d --build +``` + +## API + +### Criar cobrança + +```http +POST /charges +Content-Type: application/json + +{"amount": 150.00} +``` + +**201 Created** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": "ACTIVE", + "amount": 150.00, + "createdAt": "2025-06-05T18:00:00Z" +} +``` -# 4. Verificar status -docker compose ps +**400 Bad Request** (amount inválido) +```json +{ + "status": 400, + "title": "Validation failed", + "detail": "Um ou mais campos são inválidos", + "errors": { "amount": "must be greater than 0" }, + "traceId": "abc123..." +} ``` ## Endpoints e observabilidade | URL | O que se vê | |---|---| +| `http://localhost:8080/swagger-ui.html` | Swagger UI — documentação interativa da API | +| `http://localhost:8080/v3/api-docs` | Spec OpenAPI 3 em JSON | | `http://localhost:8080/ping` | `{"status":"pong"}` — smoke test | | `http://localhost:8080/actuator/health` | Status do app, banco e dependências | | `http://localhost:8080/actuator/prometheus` | Métricas no formato Prometheus | -| `http://localhost:8090` | Kafka UI — tópicos, consumer groups, mensagens | | `http://localhost:9090` | Prometheus — séries temporais, targets ativos | -| `http://localhost:3000` | Grafana — dashboard "Payment Overview" (admin/admin) | +| `http://localhost:3000` | Grafana — dashboard "Payment Overview" (credenciais do `.env`) | | `http://localhost:16686` | Jaeger — traces distribuídos por operação | +| `http://localhost:8090` | Kafka UI — tópicos, consumer groups, mensagens | ## Testes ```bash -# Unitários — sem Docker, rápido -make test -# equivalente: ./mvnw test +# Unitários — sem Docker, rápido (~2s) +./mvnw test -# Integração — sobe PostgreSQL 18 via Testcontainers -make verify -# equivalente: ./mvnw verify +# Integração + cobertura — sobe PostgreSQL 18 via Testcontainers +./mvnw verify ``` +| Teste | Tipo | O que cobre | +|---|---|---| +| `MoneyTest` | Unit | Validação de amount (7 casos table-driven) | +| `ChargeTest` | Unit | `create()`, `restore()`, `equals/hashCode` | +| `CreateChargeServiceTest` | Unit | Sucesso (3 valores) + erros de validação | +| `ChargeRepositoryIT` | Integration | Round-trip save/findById com PostgreSQL 18 real | +| `ChargeControllerIT` | Integration | POST 201, POST 400 Problem Details | + Convenção de nomes: -- `*Test.java` — testes unitários, executados pelo Surefire -- `*IT.java` — testes de integração, executados pelo Failsafe +- `*Test.java` — unitários, Surefire +- `*IT.java` — integração, Failsafe + Testcontainers -O teste `PaymentApiApplicationIT` valida que o contexto Spring sobe corretamente contra um banco PostgreSQL real, sem mocks. +Relatório JaCoCo gerado em `target/site/jacoco/index.html` após `./mvnw verify`. ## CI/CD | Trigger | Job | O que roda | |---|---|---| -| Push para `develop` ou `main` | Lint + Unit Tests | `spotless:check` → `checkstyle:check` → `mvnw test` | +| Push para `epic/**`, `develop` ou `main` | Lint + Unit Tests | `spotless:check` → `checkstyle:check` → `mvnw test` | | Push para `main` ou PR → `main` | Full Verify + Docker Build | `mvnw verify` (unit + integração) → `docker build` | -O job de integração roda apenas no caminho para `main`, mantendo o ciclo de feedback rápido no `develop`. - ## Padrões -**Formatação e estilo:** - ```bash ./mvnw spotless:apply # formata (Google Java Format, AOSP 4-space) ./mvnw spotless:check # verifica (roda no CI) ./mvnw checkstyle:check # estilo (roda no CI) ``` -**Git hooks** (ativar uma vez por clone): - +**Git hooks** — shell scripts em `.githooks/` (ativar uma vez por clone): ```bash git config core.hooksPath .githooks ``` @@ -134,13 +170,14 @@ git config core.hooksPath .githooks | `commit-msg` | Valida formato Conventional Commits | | `pre-push` | Executa `./mvnw verify` antes de subir | -Formato de commit: `tipo(escopo): descrição` — tipos aceitos: `feat fix docs style refactor test chore build ci perf revert`. +Formato de commit: `tipo(escopo): descrição` — tipos: `feat fix docs style refactor test chore build ci perf revert`. -**Makefile:** +> **Nota:** os hooks são scripts shell nativos (`.githooks/`). Husky está previsto para substituí-los em versão futura. +**Makefile:** ``` make up # docker compose up -d --build -make down # docker compose down (mantém volumes) +make down # docker compose down make clean # docker compose down -v (remove volumes) make logs # docker compose logs -f app make test # ./mvnw test @@ -149,19 +186,6 @@ make format # ./mvnw spotless:apply make db # psql no container postgres ``` -## Roadmap - -O que está especificado em [`docs/architecture.md`](docs/architecture.md) e ainda não implementado: - -- [ ] Entidade `Charge` com value object `Money` e `ChargeStatus` -- [ ] Máquina de estados (`PENDING → ACTIVE → PAID / EXPIRED / CANCELLED`) -- [ ] Caso de uso `CreateCharge` com idempotência por header -- [ ] Adapter de persistência JPA (`ChargeRepository`) -- [ ] Publicação de eventos de domínio no Kafka (`ChargeCreated`, `ChargePaid`, etc.) -- [ ] Consumer para eventos externos (webhook/notificação) -- [ ] Uso do Redis para cache de idempotência e locks -- [ ] Endpoints REST de cobrança (`POST /charges`, `GET /charges/{id}`) - --- [Leandro Moraes](https://github.com/lmoraesdev) diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..7df1dcb --- /dev/null +++ b/TESTING.md @@ -0,0 +1,181 @@ +# Testing Standard (Java) — portado do Test Table Pattern + +Este é o padrão de testes do projeto, adaptado do meu padrão antigo de Node/TS +para os idiomas nativos do Java. Regra de ouro: **manter os princípios, NÃO +recriar a camada de helpers** — JUnit 5 + AssertJ + Mockito já entregam isso. + +## Princípios que vêm do padrão antigo (valem 100%) + +- Table-driven: casos de teste como dados, não como `it()` duplicados. +- Builders pra entidades de domínio; fixtures pra payloads complexos. +- Separar claramente "Success" e "Validation errors". +- Mapear TODAS as branches antes de escrever (if/else/try/for/switch e combinações). +- Testar o comportamento REAL do código, não o assumido. +- Testar resiliência em observer/orchestrator (falha parcial não derruba o fluxo). +- Refatorar use case complexo em métodos privados de responsabilidade única. +- Cobertura: use cases críticos ~100% (sucesso + erros esperados). + +## Mapa Node/TS → Java (o que muda) + +| Padrão antigo (vitest+sinon) | Equivalente NATIVO em Java | Veredito | +| ----------------------------------- | --------------------------------------------------- | -------- | +| `runTests(testCases, cb)` | `@ParameterizedTest` + `@MethodSource` | usar nativo, não recriar | +| `testCases: TestCase[]` | `Stream` (record) via `@MethodSource` | porta fiel e idiomática | +| `it.skip` (testType) | `@Disabled("motivo")` | nativo | +| `it.only` | rodar 1 teste (IDE / `-Dtest=Classe#metodo`) | não existe data-driven; ok | +| `getStub(sandbox, X.prototype, m)` | `@Mock` + `when(mock.m()).thenReturn/thenThrow` | usar nativo | +| `executeStubAssertions(callMatchs)` | `verify(mock).m(args)`, `times(n)`, `ArgumentCaptor`| usar nativo | +| `getError(cb)` | `assertThatThrownBy(() -> ...)` / `catchThrowable` | usar nativo | +| DI container `container.get(...)` | unit: `new Service(mock)`; integração: `@SpringBootTest` | ver nota | +| builders `.getData()` / `.build()` | builder retorna domínio; persistir = via repository | manter | +| fixtures `*-fixture.ts` | `*Fixtures` (factory estática) ou builder | manter | +| cobertura (vitest) | JaCoCo (`jacoco-maven-plugin`) | trocar ferramenta | + +## O que NÃO portar (e por quê) + +- **O DSL declarativo de stubs** (`input.stubs.x.resolves` + `output.stubs.callMatchs`). + Em Java, parametrize os INPUTS e os EXPECTED; o setup de stub e o `verify` ficam + imperativos no corpo do teste, com Mockito. Tentar codar stub-como-dado vira um + mini-framework que briga com a linguagem. +- **Custom runner** (`runTests`). `@ParameterizedTest` é o runner. +- **DI container no unit test.** Em Java, teste UNITÁRIO instancia direto + (`new CreateChargeService(mockRepo)`) — mais rápido e isolado, sem subir Spring. + Container só em teste de INTEGRAÇÃO (`@SpringBootTest`/`@DataJpaTest`). + +## Onde o Java é ATÉ melhor + +Seu padrão antigo dizia "stubar classes concretas, não interfaces" (limitação do +sinon). No Java/Mockito você mocka a **interface** (a porta `ChargeRepository`) +diretamente — que é a inversão de dependência feita certo. Mais limpo, e alinhado +com a clean arch. + +## Stack + +JUnit 5 (Jupiter) · AssertJ · Mockito · Testcontainers · JaCoCo (cobertura). +Tudo via `spring-boot-starter-test`, menos o JaCoCo (plugin no pom). + +--- + +## Exemplo 1 — Table-driven (o "testCases" do jeito Java) + +```java +@DisplayName("Money") +class MoneyTest { + + record Case(String name, String amount, boolean valid) { + @Override public String toString() { return name; } + } + + static Stream cases() { + return Stream.of( + new Case("aceita centavo mínimo", "0.01", true), + new Case("aceita valor comum", "10.50", true), + new Case("rejeita negativo", "-1.00", false), + new Case("rejeita zero", "0.00", false) + ); + } + + @ParameterizedTest + @MethodSource("cases") + void validates(Case c) { + if (c.valid()) { + assertThat(new Money(new BigDecimal(c.amount())).amount()) + .isEqualByComparingTo(c.amount()); + } else { + assertThatThrownBy(() -> new Money(new BigDecimal(c.amount()))) + .isInstanceOf(IllegalArgumentException.class); + } + } +} +``` + +## Exemplo 2 — Test Data Builder (BASE + sobrescreve só o que muda) + +```java +public final class ChargeTestData { + private Money amount = new Money(new BigDecimal("10.50")); + private ChargeStatus status = ChargeStatus.ACTIVE; + + public static ChargeTestData aCharge() { return new ChargeTestData(); } + public static Money money(String v) { return new Money(new BigDecimal(v)); } + + public ChargeTestData withAmount(String v) { + this.amount = new Money(new BigDecimal(v)); + return this; + } + public ChargeTestData withStatus(ChargeStatus s) { + this.status = s; + return this; + } + public Charge build() { + return Charge.restore(UUID.randomUUID(), amount, status, Instant.now()); + } +} +// uso: ChargeTestData.aCharge().withAmount("99.90").build(); +``` + +## Exemplo 3 — Use case com mocks (sucesso + verificação) + +```java +@ExtendWith(MockitoExtension.class) +class CreateChargeServiceTest { + + @Mock ChargeRepository repository; + @InjectMocks CreateChargeService service; + + @Nested @DisplayName("Success") + class Success { + @Test + void cria_e_persiste_a_cobranca() { + when(repository.save(any())).thenAnswer(i -> i.getArgument(0)); + + var result = service.create(new CreateChargeCommand(new BigDecimal("10.50"))); + + var captor = ArgumentCaptor.forClass(Charge.class); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(ChargeStatus.ACTIVE); + assertThat(result.amount()).isEqualByComparingTo("10.50"); + } + } + + @Nested @DisplayName("Validation errors") + class ValidationErrors { + @Test + void nao_persiste_quando_valor_invalido() { + assertThatThrownBy(() -> + service.create(new CreateChargeCommand(new BigDecimal("-1")))) + .isInstanceOf(IllegalArgumentException.class); + + verify(repository, never()).save(any()); + } + } +} +``` + +## Exemplo 4 — Resiliência de orchestrator (quando existir) + +```java +when(subscriberA.notify(any())).thenReturn(...); +doThrow(new RuntimeException("falhou")).when(subscriberB).notify(any()); +// act +service.execute(event); +// assert: B falhou MAS C ainda foi chamado +verify(subscriberC).notify(any()); +``` + +## Convenções + +- `*Test` → unitário (surefire, fase `test`); `*IT` → integração (failsafe, fase `verify`). +- `@Nested` pra agrupar Success / ValidationErrors. `@DisplayName` em tudo. +- Dados de borda = fixos e determinísticos. `datafaker` só pra preenchimento "qualquer válido". +- BigDecimal: sempre `isEqualByComparingTo`, nunca `isEqualTo`. + +## Checklist (adaptado do antigo) + +- [ ] Todas as branches cobertas? (mapeie antes de escrever) +- [ ] Sucesso e erro testados? Early returns cobertos? +- [ ] Mockou a INTERFACE (porta), não a implementação concreta? +- [ ] Unit test sem Spring (new + mock); integração com @SpringBootTest/Testcontainers? +- [ ] Verificou chamadas dos mocks (verify/ArgumentCaptor)? +- [ ] Teste reflete o comportamento REAL (não o assumido)? +- [ ] Sem helper de infra caseiro reinventando JUnit/Mockito/AssertJ? diff --git a/docker-compose.yml b/docker-compose.yml index cdd763b..989b726 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,7 @@ services: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + SPRING_APPLICATION_NAME: payment-api SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:29092 # Traces vão para o Jaeger via OTLP HTTP OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318/v1/traces diff --git a/docs/devops-flow.png b/docs/devops-flow.png deleted file mode 100644 index e1cddec..0000000 Binary files a/docs/devops-flow.png and /dev/null differ diff --git a/pom.xml b/pom.xml index cf7d602..4fe6da7 100644 --- a/pom.xml +++ b/pom.xml @@ -84,12 +84,25 @@ true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.17 + + org.springframework.boot spring-boot-starter-test test + + net.datafaker + datafaker + 2.4.2 + test + org.springframework.boot spring-boot-testcontainers @@ -150,6 +163,8 @@ + + @{argLine} 1.44 @@ -157,6 +172,28 @@ + + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java new file mode 100644 index 0000000..dfc023c --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java @@ -0,0 +1,63 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import com.lmoraesdev.payment.application.port.in.CreateCharge; +import com.lmoraesdev.payment.application.port.in.CreateChargeCommand; +import com.lmoraesdev.payment.application.port.in.CreateChargeResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Charges", description = "Gerenciamento de cobranças Pix") +@RestController +@RequestMapping("/charges") +public class ChargeController { + private final CreateCharge createCharge; + + public ChargeController(CreateCharge createCharge) { + this.createCharge = createCharge; + } + + @Operation( + summary = "Criar cobrança", + description = "Cria uma nova cobrança Pix com status ACTIVE") + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "Cobrança criada com sucesso", + content = @Content(schema = @Schema(implementation = CreateChargeResponse.class))), + @ApiResponse( + responseCode = "400", + description = "Dados inválidos — Problem Details com erros por campo", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))), + @ApiResponse( + responseCode = "422", + description = "Regra de negócio violada", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))), + @ApiResponse( + responseCode = "500", + description = "Erro interno inesperado", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))) + }) + @PostMapping + public ResponseEntity create( + @Valid @RequestBody CreateChargeRequest request) { + CreateChargeResult result = createCharge.create(new CreateChargeCommand(request.amount())); + + CreateChargeResponse response = + new CreateChargeResponse( + result.id(), result.status(), result.amount(), result.createdAt()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java new file mode 100644 index 0000000..c81ad2d --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java @@ -0,0 +1,13 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.math.BigDecimal; + +@Schema(description = "Requisição para criar uma cobrança Pix") +public record CreateChargeRequest( + @Schema(description = "Valor da cobrança em reais", example = "150.00", minimum = "0.01") + @NotNull + @Positive + BigDecimal amount) {} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java new file mode 100644 index 0000000..b64b692 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java @@ -0,0 +1,13 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Schema(description = "Cobrança Pix criada") +public record CreateChargeResponse( + @Schema(description = "Identificador único da cobrança") UUID id, + @Schema(description = "Status da cobrança", example = "ACTIVE") String status, + @Schema(description = "Valor da cobrança em reais", example = "150.00") BigDecimal amount, + @Schema(description = "Data e hora de criação (UTC)") Instant createdAt) {} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java index 65cee46..df614e1 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java @@ -1,18 +1,51 @@ package com.lmoraesdev.payment.adapter.in.web; import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; -import java.time.LocalDateTime; +import com.lmoraesdev.payment.domain.exception.DomainException; +import java.util.HashMap; import java.util.Map; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +@Order(Ordered.HIGHEST_PRECEDENCE) @RestControllerAdvice public class GlobalExceptionHandler { + // 400 — validação. Erro esperado do cliente: NÃO loga. + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, "Um ou mais campos são inválidos"); + problem.setTitle("Validation failed"); + Map errors = new HashMap<>(); + ex.getBindingResult() + .getFieldErrors() + .forEach(e -> errors.put(e.getField(), e.getDefaultMessage())); + problem.setProperty("errors", errors); + addTraceId(problem); + return problem; + } + + // 422 — regra de negócio. Também esperado: NÃO loga. + @ExceptionHandler(DomainException.class) + public ProblemDetail handleDomain(DomainException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage()); + problem.setTitle("Business rule violation"); + addTraceId(problem); + return problem; + } + + // 500 — inesperado. AQUI sim loga, em ERROR, com a stack. @ExceptionHandler(Exception.class) - public ResponseEntity> handleException(Exception ex) { + public ProblemDetail handleUnexpected(Exception ex) { Logger5w1hBuilder.create(GlobalExceptionHandler.class) .where("GlobalExceptionHandler") .what("unexpected_error") @@ -21,7 +54,18 @@ public ResponseEntity> handleException(Exception ex) { .how("exception handling") .error(ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Unexpected error", "timestamp", LocalDateTime.now())); + ProblemDetail problem = + ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, "Erro interno inesperado"); + problem.setTitle("Internal error"); + addTraceId(problem); + return problem; + } + + private void addTraceId(ProblemDetail problem) { + String traceId = MDC.get("traceId"); + if (traceId != null) { + problem.setProperty("traceId", traceId); + } } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/.gitkeep b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java new file mode 100644 index 0000000..23bea11 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java @@ -0,0 +1,61 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "charges") +public class ChargeJpaEntity { + @Id private UUID id; + + @Column(name = "amount_centavos", nullable = false) + private Long amountCentavos; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ChargeStatus status; + + @Column(nullable = false) + private Instant createdAt; + + protected ChargeJpaEntity() {} + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Long getAmountCentavos() { + return amountCentavos; + } + + public void setAmountCentavos(Long amountCentavos) { + this.amountCentavos = amountCentavos; + } + + public ChargeStatus getStatus() { + return status; + } + + public void setStatus(ChargeStatus status) { + this.status = status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java new file mode 100644 index 0000000..0dacf77 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java @@ -0,0 +1,29 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.Money; +import java.math.BigDecimal; + +final class ChargeMapper { + private ChargeMapper() {} + + static ChargeJpaEntity toEntity(Charge charge) { + ChargeJpaEntity entity = new ChargeJpaEntity(); + + entity.setId(charge.getId()); + entity.setAmountCentavos(charge.getAmount().amount().movePointRight(2).longValueExact()); + entity.setStatus(charge.getStatus()); + entity.setCreatedAt(charge.getCreatedAt()); + + return entity; + } + + static Charge toDomain(ChargeJpaEntity entity) { + + return Charge.restore( + entity.getId(), + new Money(BigDecimal.valueOf(entity.getAmountCentavos(), 2)), + entity.getStatus(), + entity.getCreatedAt()); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java new file mode 100644 index 0000000..3446f69 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java @@ -0,0 +1,30 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.domain.model.Charge; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public class ChargeRepositoryAdapter implements ChargeRepository { + private final SpringDataChargeRepository repository; + + public ChargeRepositoryAdapter(SpringDataChargeRepository repository) { + this.repository = repository; + } + + @Override + public Charge save(Charge charge) { + ChargeJpaEntity entity = ChargeMapper.toEntity(charge); + + ChargeJpaEntity saved = repository.save(entity); + + return ChargeMapper.toDomain(saved); + } + + @Override + public Optional findById(UUID id) { + return repository.findById(id).map(ChargeMapper::toDomain); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java new file mode 100644 index 0000000..1ac5203 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java @@ -0,0 +1,6 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SpringDataChargeRepository extends JpaRepository {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/.gitkeep b/src/main/java/com/lmoraesdev/payment/application/port/in/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java new file mode 100644 index 0000000..50b81ec --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java @@ -0,0 +1,5 @@ +package com.lmoraesdev.payment.application.port.in; + +public interface CreateCharge { + CreateChargeResult create(CreateChargeCommand command); +} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java new file mode 100644 index 0000000..0027294 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java @@ -0,0 +1,5 @@ +package com.lmoraesdev.payment.application.port.in; + +import java.math.BigDecimal; + +public record CreateChargeCommand(BigDecimal amount) {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java new file mode 100644 index 0000000..748d461 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java @@ -0,0 +1,7 @@ +package com.lmoraesdev.payment.application.port.in; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record CreateChargeResult(UUID id, String status, BigDecimal amount, Instant createdAt) {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/out/.gitkeep b/src/main/java/com/lmoraesdev/payment/application/port/out/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java b/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java new file mode 100644 index 0000000..1597f53 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java @@ -0,0 +1,11 @@ +package com.lmoraesdev.payment.application.port.out; + +import com.lmoraesdev.payment.domain.model.Charge; +import java.util.Optional; +import java.util.UUID; + +public interface ChargeRepository { + Charge save(Charge charge); + + Optional findById(UUID id); +} diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/.gitkeep b/src/main/java/com/lmoraesdev/payment/application/usecase/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java new file mode 100644 index 0000000..c309a43 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java @@ -0,0 +1,43 @@ +package com.lmoraesdev.payment.application.usecase; + +import com.lmoraesdev.payment.application.port.in.CreateCharge; +import com.lmoraesdev.payment.application.port.in.CreateChargeCommand; +import com.lmoraesdev.payment.application.port.in.CreateChargeResult; +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.Money; +import org.springframework.stereotype.Service; + +@Service +public class CreateChargeService implements CreateCharge { + private final ChargeRepository chargeRepository; + + public CreateChargeService(ChargeRepository chargeRepository) { + this.chargeRepository = chargeRepository; + } + + @Override + public CreateChargeResult create(CreateChargeCommand command) { + + Money amount = new Money(command.amount()); + + Charge charge = Charge.create(amount); + + Charge saved = chargeRepository.save(charge); + + Logger5w1hBuilder.create(CreateChargeService.class) + .where("CreateChargeService") + .what("charge_created") + .why("charge creation requested") + .who("system") + .how("createCharge") + .info(); + + return new CreateChargeResult( + saved.getId(), + saved.getStatus().name(), + saved.getAmount().amount(), + saved.getCreatedAt()); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/config/OpenApiConfig.java b/src/main/java/com/lmoraesdev/payment/config/OpenApiConfig.java new file mode 100644 index 0000000..f977486 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/config/OpenApiConfig.java @@ -0,0 +1,26 @@ +package com.lmoraesdev.payment.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info( + new Info() + .title("Payment API") + .description( + "Core de pagamentos Pix — Hexagonal Architecture, Spring Boot 3, PostgreSQL 18") + .version("0.1.0") + .contact( + new Contact() + .name("Leandro Moraes") + .url("https://github.com/lmoraesdev"))); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java b/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java index cb4bbbf..8256c02 100644 --- a/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java @@ -1,11 +1,3 @@ package com.lmoraesdev.payment.config.logging; -public record Log5w1h( - String traceId, - String spanId, - String where, - String why, - String when, - Object who, - Object what, - String how) {} +public record Log5w1h(String where, String why, String who, String what, String how) {} diff --git a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java index 350f5f2..031b531 100644 --- a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java @@ -1,13 +1,11 @@ package com.lmoraesdev.payment.config.logging; -import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.spi.LoggingEventBuilder; public class Logger5w1h { - private static final ObjectMapper MAPPER = new ObjectMapper(); - private final Logger logger; private Logger5w1h(Class clazz) { @@ -19,26 +17,26 @@ public static Logger5w1h of(Class clazz) { } public void info(Log5w1h data) { - logger.info(toJson(data)); + withFields(logger.atInfo(), data).log(); } public void debug(Log5w1h data) { - logger.debug(toJson(data)); + withFields(logger.atDebug(), data).log(); } public void warn(Log5w1h data) { - logger.warn(toJson(data)); + withFields(logger.atWarn(), data).log(); } - public void error(Log5w1h data, Throwable exception) { - logger.error(toJson(data), exception); + public void error(Log5w1h data, Throwable cause) { + withFields(logger.atError(), data).setCause(cause).log(); } - private String toJson(Log5w1h data) { - try { - return MAPPER.writeValueAsString(data); - } catch (Exception exception) { - return data.toString(); - } + private LoggingEventBuilder withFields(LoggingEventBuilder builder, Log5w1h data) { + return builder.addKeyValue("where", data.where()) + .addKeyValue("why", data.why()) + .addKeyValue("who", data.who()) + .addKeyValue("what", data.what()) + .addKeyValue("how", data.how()); } } diff --git a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java index 2cd43c5..9db2d5f 100644 --- a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java @@ -1,22 +1,17 @@ package com.lmoraesdev.payment.config.logging; -import java.time.LocalDateTime; -import org.slf4j.MDC; - public class Logger5w1hBuilder { private final Logger5w1h logger; private String where; private String why; - private String when; - private Object who; - private Object what; + private String who; + private String what; private String how; private Logger5w1hBuilder(Class clazz) { this.logger = Logger5w1h.of(clazz); - this.when = LocalDateTime.now().toString(); } public static Logger5w1hBuilder create(Class clazz) { @@ -33,17 +28,12 @@ public Logger5w1hBuilder why(String value) { return this; } - public Logger5w1hBuilder when(String value) { - this.when = value; - return this; - } - - public Logger5w1hBuilder who(Object value) { + public Logger5w1hBuilder who(String value) { this.who = value; return this; } - public Logger5w1hBuilder what(Object value) { + public Logger5w1hBuilder what(String value) { this.what = value; return this; } @@ -65,13 +55,11 @@ public void warn() { logger.warn(build()); } - public void error(Throwable e) { - logger.error(build(), e); + public void error(Throwable cause) { + logger.error(build(), cause); } private Log5w1h build() { - // traceId is injected automatically from Micrometer Tracing MDC — - // no caller needs to pass it explicitly. - return new Log5w1h(MDC.get("traceId"), MDC.get("spanId"), where, why, when, who, what, how); + return new Log5w1h(where, why, who, what, how); } } diff --git a/src/main/java/com/lmoraesdev/payment/domain/exception/InvalidAmountException.java b/src/main/java/com/lmoraesdev/payment/domain/exception/InvalidAmountException.java new file mode 100644 index 0000000..b6b548c --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/exception/InvalidAmountException.java @@ -0,0 +1,8 @@ +package com.lmoraesdev.payment.domain.exception; + +public class InvalidAmountException extends DomainException { + + public InvalidAmountException(String message) { + super("INVALID_AMOUNT", message); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/.gitkeep b/src/main/java/com/lmoraesdev/payment/domain/model/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java new file mode 100644 index 0000000..5e4544f --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java @@ -0,0 +1,66 @@ +package com.lmoraesdev.payment.domain.model; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public class Charge { + private final UUID id; + private final Money amount; + private ChargeStatus status; + private final Instant createdAt; + + private Charge(UUID id, Money amount, ChargeStatus status, Instant createdAt) { + this.id = id; + this.amount = amount; + this.status = status; + this.createdAt = createdAt; + } + + public static Charge create(Money amount) { + Objects.requireNonNull(amount, "O montante (Money) é obrigatório"); + + return new Charge(UUID.randomUUID(), amount, ChargeStatus.ACTIVE, Instant.now()); + } + + public static Charge restore(UUID id, Money amount, ChargeStatus status, Instant createdAt) { + Objects.requireNonNull(id, "O id é obrigatório"); + Objects.requireNonNull(amount, "O montante (Money) é obrigatório"); + Objects.requireNonNull(status, "O status é obrigatório"); + Objects.requireNonNull(createdAt, "A data de criação é obrigatória"); + + return new Charge(id, amount, status, createdAt); + } + + public UUID getId() { + return id; + } + + public Money getAmount() { + return amount; + } + + public ChargeStatus getStatus() { + return status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Charge other)) { + return false; + } + return id.equals(other.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java b/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java new file mode 100644 index 0000000..b0620ae --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java @@ -0,0 +1,8 @@ +package com.lmoraesdev.payment.domain.model; + +public enum ChargeStatus { + ACTIVE, + PAID, + EXPIRED, + CANCELLED +} diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Money.java b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java new file mode 100644 index 0000000..9b2b6bc --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java @@ -0,0 +1,21 @@ +package com.lmoraesdev.payment.domain.model; + +import com.lmoraesdev.payment.domain.exception.InvalidAmountException; +import java.math.BigDecimal; +import java.math.RoundingMode; + +public record Money(BigDecimal amount) { + public Money { + if (amount == null) { + throw new InvalidAmountException("amount is required"); + } + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new InvalidAmountException("amount must be greater than zero"); + } + try { + amount = amount.setScale(2, RoundingMode.UNNECESSARY); + } catch (ArithmeticException e) { + throw new InvalidAmountException("amount must have at most 2 decimal places"); + } + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..e309730 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,4 @@ +logging: + structured: + format: + console: ecs diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b9b966e..b4b1255 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,11 +1,13 @@ spring: + application: + name: payment-api datasource: url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/payment_db} username: ${SPRING_DATASOURCE_USERNAME:admin} password: ${SPRING_DATASOURCE_PASSWORD:admin_pass} jpa: hibernate: - ddl-auto: none + ddl-auto: update open-in-view: false properties: hibernate: @@ -20,8 +22,13 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer + mvc: + problemdetails: + enabled: true data: redis: + repositories: + enabled: false host: ${SPRING_REDIS_HOST:localhost} port: ${SPRING_REDIS_PORT:6379} @@ -38,15 +45,18 @@ management: tracing: sampling: probability: 1.0 - opentelemetry: + otlp: tracing: - export: - otlp: - endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318/v1/traces} + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318/v1/traces} + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + operations-sorter: method logging: level: root: INFO com.lmoraesdev.payment: DEBUG - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %X{traceId} %logger{36} - %msg%n" diff --git a/src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java b/src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java deleted file mode 100644 index 5c30881..0000000 --- a/src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.lmoraesdev.payment; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.test.context.ActiveProfiles; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -@SpringBootTest -@Testcontainers -@ActiveProfiles("test") -class PaymentApiApplicationIT { - - @Container @ServiceConnection - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18-alpine"); - - @Test - void contextLoads() {} -} diff --git a/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java b/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java new file mode 100644 index 0000000..72855b5 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java @@ -0,0 +1,61 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.lmoraesdev.payment.support.TestcontainersConfiguration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +@ActiveProfiles("test") +@DisplayName("ChargeControllerIT") +class ChargeControllerIT { + + @Autowired MockMvc mockMvc; + + @Test + @DisplayName("POST /charges com amount válido → 201 com id, status e createdAt") + void createChargeReturns201() throws Exception { + mockMvc.perform( + post("/charges") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100.00}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.amount").value(100.00)) + .andExpect(jsonPath("$.createdAt").isNotEmpty()); + } + + @Test + @DisplayName("POST /charges com amount negativo → 400 Problem Details com errors.amount") + void createChargeWithNegativeAmountReturns400() throws Exception { + mockMvc.perform( + post("/charges") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": -1}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.errors.amount").exists()); + } + + @Test + @DisplayName("POST /charges sem amount → 400 Problem Details com errors.amount") + void createChargeWithMissingAmountReturns400() throws Exception { + mockMvc.perform(post("/charges").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.errors.amount").exists()); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java new file mode 100644 index 0000000..48343be --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java @@ -0,0 +1,44 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.support.AbstractIntegrationTest; +import com.lmoraesdev.payment.testdata.ChargeTestData; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import(ChargeRepositoryAdapter.class) +@DisplayName("ChargeRepositoryIT") +class ChargeRepositoryIT extends AbstractIntegrationTest { + + @Autowired ChargeRepository chargeRepository; + + @Test + @DisplayName("save e findById preservam todos os campos") + void roundTripPreservesAllFields() { + Charge charge = ChargeTestData.aCharge().build(); + + Charge saved = chargeRepository.save(charge); + Optional found = chargeRepository.findById(saved.getId()); + + assertThat(found).isPresent(); + Charge result = found.get(); + assertThat(result.getId()).isEqualTo(charge.getId()); + assertThat(result.getAmount().amount()).isEqualByComparingTo(charge.getAmount().amount()); + assertThat(result.getStatus()).isEqualTo(charge.getStatus()); + assertThat(result.getCreatedAt()).isEqualTo(charge.getCreatedAt()); + } + + @Test + @DisplayName("findById retorna vazio para id inexistente") + void findByIdReturnsEmptyForUnknownId() { + Optional found = chargeRepository.findById(java.util.UUID.randomUUID()); + + assertThat(found).isEmpty(); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java b/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java new file mode 100644 index 0000000..3968028 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java @@ -0,0 +1,76 @@ +package com.lmoraesdev.payment.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.lmoraesdev.payment.application.port.in.CreateChargeCommand; +import com.lmoraesdev.payment.application.port.in.CreateChargeResult; +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.domain.exception.InvalidAmountException; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import java.math.BigDecimal; +import java.util.stream.Stream; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("CreateChargeService") +@ExtendWith(MockitoExtension.class) +class CreateChargeServiceTest { + + @Mock ChargeRepository chargeRepository; + + @InjectMocks CreateChargeService service; + + record Case(String name, String amount) { + @Override + public String toString() { + return name; + } + } + + static Stream validAmounts() { + return Stream.of( + new Case("centavo mínimo", "0.01"), + new Case("valor comum", "100.00"), + new Case("valor alto", "50000.00")); + } + + @ParameterizedTest + @MethodSource("validAmounts") + @DisplayName("cria cobrança e retorna result com status ACTIVE") + void createsChargeSuccessfully(Case c) { + when(chargeRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + CreateChargeResult result = + service.create(new CreateChargeCommand(new BigDecimal(c.amount()))); + + assertThat(result.id()).isNotNull(); + assertThat(result.status()).isEqualTo(ChargeStatus.ACTIVE.name()); + assertThat(result.amount()).isEqualByComparingTo(c.amount()); + assertThat(result.createdAt()).isNotNull(); + verify(chargeRepository).save(any()); + } + + @Test + @DisplayName("propaga InvalidAmountException para amount zero ou negativo") + void propagatesExceptionForInvalidAmount() { + assertThatThrownBy(() -> service.create(new CreateChargeCommand(BigDecimal.ZERO))) + .isInstanceOf(InvalidAmountException.class); + } + + @Test + @DisplayName("propaga InvalidAmountException para amount nulo") + void propagatesExceptionForNullAmount() { + assertThatThrownBy(() -> service.create(new CreateChargeCommand(null))) + .isInstanceOf(InvalidAmountException.class); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java b/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java new file mode 100644 index 0000000..fdc9e53 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java @@ -0,0 +1,63 @@ +package com.lmoraesdev.payment.domain.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Charge") +class ChargeTest { + + private final Money amount = new Money(new BigDecimal("10.00")); + + @Test + @DisplayName("create gera id único e inicia ACTIVE") + void createGeneratesUniqueIdAndStartsActive() { + Charge a = Charge.create(amount); + Charge b = Charge.create(amount); + + assertThat(a.getStatus()).isEqualTo(ChargeStatus.ACTIVE); + assertThat(a.getId()).isNotNull(); + assertThat(a.getCreatedAt()).isNotNull(); + assertThat(a.getId()).isNotEqualTo(b.getId()); + } + + @Test + @DisplayName("create rejeita amount nulo") + void createRejectsNullAmount() { + assertThatThrownBy(() -> Charge.create(null)).isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("restore reconstitui sem gerar novo id ou data") + void restorePreservesIdAndTimestamp() { + UUID id = UUID.randomUUID(); + Instant createdAt = Instant.parse("2025-01-01T00:00:00Z"); + + Charge charge = Charge.restore(id, amount, ChargeStatus.PAID, createdAt); + + assertThat(charge.getId()).isEqualTo(id); + assertThat(charge.getStatus()).isEqualTo(ChargeStatus.PAID); + assertThat(charge.getAmount()).isEqualTo(amount); + assertThat(charge.getCreatedAt()).isEqualTo(createdAt); + } + + @Test + @DisplayName("equals e hashCode baseados somente no id") + void equalityIsIdBased() { + UUID id = UUID.randomUUID(); + Charge a = Charge.restore(id, amount, ChargeStatus.ACTIVE, Instant.now()); + Charge b = + Charge.restore( + id, new Money(new BigDecimal("99.00")), ChargeStatus.PAID, Instant.now()); + Charge c = Charge.restore(UUID.randomUUID(), amount, ChargeStatus.ACTIVE, Instant.now()); + + assertThat(a).isEqualTo(b); + assertThat(a).isNotEqualTo(c); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java b/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java new file mode 100644 index 0000000..b06c5c3 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java @@ -0,0 +1,50 @@ +package com.lmoraesdev.payment.domain.model; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.lmoraesdev.payment.domain.exception.InvalidAmountException; +import java.math.BigDecimal; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("Money") +class MoneyTest { + + record Case(String name, String amount, boolean valid) { + @Override + public String toString() { + return name; + } + } + + static Stream cases() { + return Stream.of( + new Case("aceita centavo mínimo", "0.01", true), + new Case("aceita valor comum", "10.50", true), + new Case("aceita valor alto", "9999999.99", true), + new Case("rejeita zero", "0.00", false), + new Case("rejeita negativo", "-1.00", false), + new Case("rejeita negativo pequeno", "-0.01", false)); + } + + @ParameterizedTest + @MethodSource("cases") + void validates(Case c) { + BigDecimal value = new BigDecimal(c.amount()); + if (c.valid()) { + assertThatNoException().isThrownBy(() -> new Money(value)); + } else { + assertThatThrownBy(() -> new Money(value)).isInstanceOf(InvalidAmountException.class); + } + } + + @Test + @DisplayName("rejeita amount nulo") + void rejectsNull() { + assertThatThrownBy(() -> new Money(null)).isInstanceOf(InvalidAmountException.class); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java b/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java new file mode 100644 index 0000000..24700db --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java @@ -0,0 +1,12 @@ +package com.lmoraesdev.payment.support; + +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@Import(TestcontainersConfiguration.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +public abstract class AbstractIntegrationTest {} diff --git a/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java b/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java new file mode 100644 index 0000000..40be1e7 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java @@ -0,0 +1,16 @@ +package com.lmoraesdev.payment.support; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; + +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>("postgres:18-alpine"); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java b/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java new file mode 100644 index 0000000..7b11682 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java @@ -0,0 +1,38 @@ +package com.lmoraesdev.payment.testdata; + +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import com.lmoraesdev.payment.domain.model.Money; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public final class ChargeTestData { + + private Money amount = new Money(new BigDecimal("10.50")); + private ChargeStatus status = ChargeStatus.ACTIVE; + + private ChargeTestData() {} + + public static ChargeTestData aCharge() { + return new ChargeTestData(); + } + + public static Money money(String value) { + return new Money(new BigDecimal(value)); + } + + public ChargeTestData withAmount(String value) { + this.amount = new Money(new BigDecimal(value)); + return this; + } + + public ChargeTestData withStatus(ChargeStatus s) { + this.status = s; + return this; + } + + public Charge build() { + return Charge.restore(UUID.randomUUID(), amount, status, Instant.now()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 1e0afb1..15196f1 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,4 +1,7 @@ spring: + mvc: + problemdetails: + enabled: true jpa: hibernate: ddl-auto: create-drop