diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c0bcafe --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d5e0d9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM eclipse-temurin:17-jdk-alpine AS build +WORKDIR /app +COPY mvnw pom.xml ./ +COPY .mvn .mvn +RUN chmod +x mvnw +RUN ./mvnw dependency:go-offline + +COPY src src +RUN ./mvnw clean package -DskipTests + +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*SNAPSHOT.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/README.md b/README.md index f91cc67..9c251c7 100644 --- a/README.md +++ b/README.md @@ -1 +1,238 @@ -# DesafioTecnicoJavaSpringFullStack +# 🏦 Desafio Técnico – Bank API +### **Java + Spring Boot + React + Docker** + +API RESTful para um sistema bancário capaz de realizar lançamentos de **débito** e **crédito** em contas de clientes, garantindo **consistência**, **concorrência segura** e uma interface simples para testes. + +Inclui: + +- ⚙️ Backend em **Java 17 + Spring Boot 3** +- 🗄 Persistência com **Spring Data JPA + PostgreSQL** +- 🔐 **Controle de concorrência** com **lock pessimista** (SELECT ... FOR UPDATE) *Optimistic Locking* (`@Version`) + - Para evitar condições de corrida (race conditions) em cenários de acesso concorrente à mesma conta, o serviço utiliza um `findByIdForUpdate` no repositório, que faz lock pessimista na linha da conta no banco de dados. Assim, apenas uma transação por vez pode modificar o saldo daquela conta, garantindo consistência. +- 🌱 **Seeds automáticos** de contas +- ❗ Tratamento global de erros padronizados +- 🧪 Testes incluindo **cenários concorrentes** +- 🐳 **Docker + docker-compose** (app + banco + frontend) +- 🖥 Frontend em **React + TypeScript + Tailwind** + +--- + +## 📚 Sumário + +1. Tecnologias +2. Arquitetura do Backend +3. Seeds +4. Como rodar com Docker +5. Como rodar localmente +6. Endpoints da API +7. Erros +8. Testes + +--- + +## 🔧 Tecnologias + +### **Backend** +- Java 17+ +- Spring Boot 3 +- Maven 3.9+ +- Docker e Docker Compose (para rodar via containers) +- Spring Web +- Spring Data JPA +- PostgreSQL (se quiser rodar localmente sem Docker) +- Lombok +- Springdoc OpenAPI (Swagger) + +### **Frontend** +- React 18 +- TypeScript +- Vite +- TailwindCSS + +### **Infra** +- Docker +- Docker Compose + +--- + +## 🧱 Arquitetura do Backend + +``` +src/main/java/com/desafiotecnico/matera + ├── MateraApplication.java + ├── account/ + │ ├── api/AccountController.java + │ ├── domain/ + │ │ ├── Account.java + │ │ ├── Transaction.java + │ │ └── TransactionType.java + │ ├── dto/ + │ │ ├── BalanceResponse.java + │ │ ├── CreateAccountRequest.java + │ │ ├── TransactionBatchRequest.java + │ │ └── TransactionRequest.java + │ ├── repository/ + │ │ ├── AccountRepository.java + │ │ └── TransactionRepository.java + │ └── service/AccountService.java + ├── config + │ ├── DataSeeder.java + │ ├── CorsConfig.java + │ ├── OpenApiConfig.java + └── shared/ + ├── error + │ ├──ApiErrorResponse.java + │ ├──ErrorResponse.java + └── exception/ + ├── ApiExceptionHandler.java + ├── BusinessException.java + ├── NotFoundException.java + └── InsufficientBalanceException.java +``` + +--- + +## 🌱 Seeds de Dados + +Criados automaticamente ao iniciar a aplicação: + +| Conta | Número | Saldo inicial | +|-------|-------------|---------------| +| Conta 1 | ACC-1001 | 1000.00 + lançamentos | +| Conta 2 | ACC-2001 | 500.00 | +| Conta 3 | ACC-3001 | 0.00 | + +--- + +## 🚀 Como rodar tudo com Docker + +### 1️⃣ Subir serviços + +- Após descompactar diretório, acessar o diretório descompactado e executar o comando: + +``` +docker compose up --build +``` + +- O banco dentro do Docker responde em db:5432 (interno) mas está exposto em localhost:5435 (externo), caso você queira acessar com DBeaver / psql. + +### URLs + +- Backend → http://localhost:8080 +- Swagger → http://localhost:8080/swagger-ui/index.html +- Frontend → http://localhost:8081 + +--- + +## ▶️ Rodar Backend localmente (sem Docker) + +### Criar banco: + +``` +CREATE DATABASE bank; +CREATE USER bankuser WITH ENCRYPTED PASSWORD 'bankpass'; +GRANT ALL PRIVILEGES ON DATABASE bank TO bankuser; +``` + +### Configurar `application.properties`: + +``` +spring.datasource.url=jdbc:postgresql://localhost:5432/bank +spring.datasource.username=bankuser +spring.datasource.password=bankpass +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +``` + +### Rodar: + +``` +./mvnw spring-boot:run +``` + +--- + +## 🌐 Endpoints da API + +- Para facilitar os testes, no diretório postman-collection contem a coleção (Matera.postman_collection.json) de testes realizados via Postman, basta baixar e importar diretamente no aplicativo + +### Criar conta +**POST** `/api/accounts` + +``` +{ + "number": "12345-0", + "initialBalance": 1000.00 +} +``` + +--- + +### Listar contas +**GET** `/api/accounts` + +--- + +### Lançamentos em lote +**POST** `/api/accounts/{id}/transactions` + +``` +{ + "transactions": [ + { "type": "DEBIT", "amount": 100.00 }, + { "type": "CREDIT", "amount": 50.00 } + ] +} +``` + +--- + +### Buscar saldo +**GET** `/api/accounts/{id}/balance` + +--- + +## ❗ Erros + +### Exemplos + +Saldo insuficiente: + +``` +{ + "code": "INSUFFICIENT_BALANCE", + "message": "Saldo insuficiente." +} +``` + +Conta não encontrada: + +``` +{ + "code": "NOT_FOUND", + "message": "Conta não encontrada" +} +``` + +Concorrência: + +``` +{ + "code": "CONCURRENT_MODIFICATION", + "message": "A conta foi modificada por outra transação." +} +``` + +--- + +## 🧪 Testes + +``` +./mvnw test +``` + +Inclui testes de: + +- Débito / Crédito +- Saldo insuficiente +- Concorrência diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f5add8f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +version: "3.8" +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: bank + POSTGRES_USER: bankuser + POSTGRES_PASSWORD: bankpass + ports: + - "5435:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + app: + build: . + depends_on: + - db + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/bank + SPRING_DATASOURCE_USERNAME: bankuser + SPRING_DATASOURCE_PASSWORD: bankpass + SPRING_JPA_HIBERNATE_DDL_AUTO: update + ports: + - "8080:8080" + + frontend: + build: ./matera-frontend + depends_on: + - app + environment: + - VITE_API_BASE_URL=http://app:8080 + ports: + - "8081:5173" + +volumes: + pgdata: diff --git a/matera-frontend/.gitignore b/matera-frontend/.gitignore new file mode 100644 index 0000000..424ff50 --- /dev/null +++ b/matera-frontend/.gitignore @@ -0,0 +1,87 @@ +# ─────────────────────────────── +# Node / Core +# ─────────────────────────────── +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +package-lock.json +.pnp +.pnp.js + +# ─────────────────────────────── +# Next.js build output +# ─────────────────────────────── +.next/ +out/ +dist/ + +# ─────────────────────────────── +# Env files +# ─────────────────────────────── +.env +.env*.local + +# ─────────────────────────────── +# Logs +# ─────────────────────────────── +logs +*.log +*.csv +*.tsv +*.pid +*.seed +*.pid.lock + +# ─────────────────────────────── +# OS / System files +# ─────────────────────────────── +.DS_Store +Thumbs.db + +# ─────────────────────────────── +# Editor / IDE configs +# ─────────────────────────────── +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +.idea/ +*.iml + +# ─────────────────────────────── +# Test / Coverage +# ─────────────────────────────── +coverage/ +.nyc_output/ + +# ─────────────────────────────── +# TypeScript +# ─────────────────────────────── +*.tsbuildinfo +next-env.d.ts + +# ─────────────────────────────── +# Runtime +# ─────────────────────────────── +*.local + +# ─────────────────────────────── +# Vercel +# ─────────────────────────────── +.vercel/ + +# ─────────────────────────────── +# Storybook (opcional) +# ─────────────────────────────── +storybook-static/ + +# ─────────────────────────────── +# Misc +# ─────────────────────────────── +*.swp +*.swo +*.zip diff --git a/matera-frontend/Dockerfile b/matera-frontend/Dockerfile new file mode 100644 index 0000000..1619b41 --- /dev/null +++ b/matera-frontend/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS build +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] diff --git a/matera-frontend/README.md b/matera-frontend/README.md new file mode 100644 index 0000000..10527cd --- /dev/null +++ b/matera-frontend/README.md @@ -0,0 +1,63 @@ +# 💳 Frontend – Bank API + +Interface web em **React + TypeScript + Vite + TailwindCSS** para consumir a Bank API desenvolvida em Spring Boot. + +O objetivo é permitir que a pessoa usuária: + +- visualize as contas disponíveis +- consulte saldos +- simule lançamentos de **débito** e **crédito** em uma conta + +--- + +## 🧰 Tecnologias + +- **React 18** +- **TypeScript** +- **Vite** +- **TailwindCSS** +- Integração com backend em **Spring Boot** via HTTP + +--- + +## ✅ Pré-requisitos + +Para rodar localmente: + +- Node.js 18+ (ou 20+) +- npm ou yarn +- Backend da Bank API rodando (ex.: em `http://localhost:8080`) + +Para rodar via Docker: + +- Docker +- Docker Compose +- Backend e banco já estão orquestrados pelo `docker-compose.yml` na raiz do projeto + +--- + +## ⚙️ Variáveis de ambiente + +O frontend utiliza uma variável para apontar para a API: + +- `VITE_API_BASE_URL` + +### Exemplos de uso + +- Ambiente local (sem Docker): + +```bash + VITE_API_BASE_URL=http://localhost:8080 +``` + +### ▶️ Rodando localmente (sem Docker): + +#### Na pasta matera-frontend: + +- instalar dependências e rodar servidor de desenvolvimento +```bash + npm install + npm run dev +``` + +- Por padrão, o Vite sobe em: http://localhost:5173/ \ No newline at end of file diff --git a/matera-frontend/index.html b/matera-frontend/index.html new file mode 100644 index 0000000..ab6bdab --- /dev/null +++ b/matera-frontend/index.html @@ -0,0 +1,12 @@ + + + + + Bank UI - Desafio Matera + + + +
+ + + diff --git a/matera-frontend/package.json b/matera-frontend/package.json new file mode 100644 index 0000000..0602247 --- /dev/null +++ b/matera-frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "matera-frontend", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.0", + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.7.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.0", + "vite": "^5.0.0" + } +} diff --git a/matera-frontend/postcss.config.cjs b/matera-frontend/postcss.config.cjs new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/matera-frontend/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/matera-frontend/src/App.tsx b/matera-frontend/src/App.tsx new file mode 100644 index 0000000..59ecfc2 --- /dev/null +++ b/matera-frontend/src/App.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from "react"; +import type { AccountSummary } from "./types"; +import { fetchAccounts } from "./api/client"; +import { AccountList } from "./components/AccountList"; +import { AccountForm } from "./components/AccountForm"; +import { TransactionsForm } from "./components/TransactionsForm"; +import { BalanceCard } from "./components/BalanceCard"; + +const API_BASE = + import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api"; + +const App: React.FC = () => { + const [accounts, setAccounts] = useState([]); + const [selectedId, setSelectedId] = useState(); + const [loadingAccounts, setLoadingAccounts] = useState(false); + + const loadAccounts = async () => { + setLoadingAccounts(true); + try { + const data = await fetchAccounts(); + setAccounts(data); + if (!selectedId && data.length > 0) { + setSelectedId(data[0].accountId); + } + } catch (err) { + console.error("Erro ao carregar contas", err); + } finally { + setLoadingAccounts(false); + } + }; + + useEffect(() => { + void loadAccounts(); + }, []); + + const selectedAccount = accounts.find((a) => a.accountId === selectedId); + + return ( +
+
+
+
+

Bank UI – Desafio Matera

+

+ Lançamentos bancários com Java + Spring Boot + React +

+
+
+ API: {API_BASE} +
+
+
+ +
+
+
+
+

Contas

+ +
+ +
+
+ +
+
+ +
+
+ + +
+
+

Como usar

+
    +
  1. Use o formulário para criar uma nova conta, se quiser.
  2. +
  3. Selecione uma conta na lista à esquerda.
  4. +
  5. Veja o saldo atual no card de saldo.
  6. +
  7. + Use o formulário de lançamentos para adicionar débitos e + créditos em lote. +
  8. +
  9. + Clique em "Atualizar lista" para ver o saldo + atualizado. +
  10. +
+
+
+
+
+ ); +}; + +export default App; diff --git a/matera-frontend/src/api/client.ts b/matera-frontend/src/api/client.ts new file mode 100644 index 0000000..fb57eca --- /dev/null +++ b/matera-frontend/src/api/client.ts @@ -0,0 +1,32 @@ +import axios from "axios"; +import type { + AccountSummary, + CreateAccountPayload, + TransactionBatchPayload +} from "../types"; + +const API_BASE = + import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api"; + +export const api = axios.create({ + baseURL: API_BASE, + headers: { + "Content-Type": "application/json" + } +}); + +export async function fetchAccounts(): Promise { + const { data } = await api.get("/accounts"); + return data; +} + +export async function createAccount(payload: CreateAccountPayload): Promise { + await api.post("/accounts", payload); +} + +export async function applyTransactions( + accountId: string, + payload: TransactionBatchPayload +): Promise { + await api.post(`/accounts/${accountId}/transactions`, payload); +} diff --git a/matera-frontend/src/components/AccountForm.tsx b/matera-frontend/src/components/AccountForm.tsx new file mode 100644 index 0000000..21e7541 --- /dev/null +++ b/matera-frontend/src/components/AccountForm.tsx @@ -0,0 +1,79 @@ +import React, { useState } from "react"; +import { createAccount } from "../api/client"; + +interface Props { + onCreated: () => void; +} + +export const AccountForm: React.FC = ({ onCreated }) => { + const [number, setNumber] = useState(""); + const [initialBalance, setInitialBalance] = useState("0"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + try { + const payload = { + number, + initialBalance: parseFloat(initialBalance || "0") + }; + await createAccount(payload); + setNumber(""); + setInitialBalance("0"); + onCreated(); + } catch (err: any) { + const message = + err?.response?.data?.message ?? "Erro ao criar conta"; + setError(message); + } finally { + setLoading(false); + } + }; + + return ( +
+

Criar nova conta

+
+
+ + setNumber(e.target.value)} + placeholder="Ex: 12345-0" + required + /> +
+
+ + setInitialBalance(e.target.value)} + /> +
+ {error && ( +

+ {error} +

+ )} + +
+
+ ); +}; diff --git a/matera-frontend/src/components/AccountList.tsx b/matera-frontend/src/components/AccountList.tsx new file mode 100644 index 0000000..0fe12bf --- /dev/null +++ b/matera-frontend/src/components/AccountList.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import type { AccountSummary } from "../types"; + +interface Props { + accounts: AccountSummary[]; + selectedId?: string; + onSelect: (accountId: string) => void; +} + +export const AccountList: React.FC = ({ + accounts, + selectedId, + onSelect +}) => { + return ( +
+

Contas disponíveis

+ {accounts.length === 0 ? ( +

+ Nenhuma conta encontrada. Crie uma nova conta ao lado. +

+ ) : ( +
    + {accounts.map((acc) => ( +
  • onSelect(acc.accountId)} + > +
    +
    {acc.number}
    +
    + ID: {acc.accountId} +
    +
    +
    + Saldo: R$ {acc.balance.toFixed(2)} +
    +
  • + ))} +
+ )} +
+ ); +}; diff --git a/matera-frontend/src/components/BalanceCard.tsx b/matera-frontend/src/components/BalanceCard.tsx new file mode 100644 index 0000000..51d3075 --- /dev/null +++ b/matera-frontend/src/components/BalanceCard.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import type { AccountSummary } from "../types"; + +interface Props { + account?: AccountSummary; +} + +export const BalanceCard: React.FC = ({ account }) => { + if (!account) { + return ( +
+

+ Selecione uma conta para visualizar o saldo. +

+
+ ); + } + + return ( +
+

+ Saldo da conta {account.number} +

+

+ R$ {account.balance.toFixed(2)} +

+

ID: {account.accountId}

+
+ ); +}; diff --git a/matera-frontend/src/components/TransactionsForm.tsx b/matera-frontend/src/components/TransactionsForm.tsx new file mode 100644 index 0000000..45021b5 --- /dev/null +++ b/matera-frontend/src/components/TransactionsForm.tsx @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import type { TransactionItem, TransactionType } from "../types"; +import { applyTransactions } from "../api/client"; + +interface Props { + accountId?: string; + onApplied: () => void; +} + +export const TransactionsForm: React.FC = ({ + accountId, + onApplied +}) => { + const [items, setItems] = useState([ + { type: "DEBIT", amount: 0 } + ]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const updateItem = (index: number, patch: Partial) => { + setItems((prev) => + prev.map((item, i) => (i === index ? { ...item, ...patch } : item)) + ); + }; + + const addItem = () => { + setItems((prev) => [...prev, { type: "CREDIT", amount: 0 }]); + }; + + const removeItem = (index: number) => { + setItems((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!accountId) { + setError("Selecione uma conta primeiro."); + return; + } + setLoading(true); + setError(null); + try { + const payload = { + transactions: items.map((i) => ({ + type: i.type, + amount: i.amount + })) + }; + await applyTransactions(accountId, payload); + onApplied(); + } catch (err: any) { + const message = + err?.response?.data?.message ?? "Erro ao aplicar lançamentos"; + setError(message); + } finally { + setLoading(false); + } + }; + + return ( +
+

Lançar débitos/créditos

+ {!accountId && ( +

+ Selecione uma conta para aplicar lançamentos. +

+ )} +
+
+ {items.map((item, index) => ( +
+ + + updateItem(index, { + amount: parseFloat(e.target.value || "0") + }) + } + placeholder="Valor" + /> + {items.length > 1 && ( + + )} +
+ ))} +
+ + {error && ( +

+ {error} +

+ )} + +
+
+ ); +}; diff --git a/matera-frontend/src/index.css b/matera-frontend/src/index.css new file mode 100644 index 0000000..25ecdba --- /dev/null +++ b/matera-frontend/src/index.css @@ -0,0 +1,8 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-slate-100; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} diff --git a/matera-frontend/src/main.tsx b/matera-frontend/src/main.tsx new file mode 100644 index 0000000..02055fd --- /dev/null +++ b/matera-frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + +); diff --git a/matera-frontend/src/types.ts b/matera-frontend/src/types.ts new file mode 100644 index 0000000..c3333af --- /dev/null +++ b/matera-frontend/src/types.ts @@ -0,0 +1,26 @@ +export interface AccountSummary { + accountId: string; + number: string; + balance: number; +} + +export interface CreateAccountPayload { + number: string; + initialBalance: number; +} + +export type TransactionType = "DEBIT" | "CREDIT"; + +export interface TransactionItem { + type: TransactionType; + amount: number; +} + +export interface TransactionBatchPayload { + transactions: TransactionItem[]; +} + +export interface ApiErrorResponse { + code: string; + message: string; +} diff --git a/matera-frontend/tailwind.config.cjs b/matera-frontend/tailwind.config.cjs new file mode 100644 index 0000000..e7d5348 --- /dev/null +++ b/matera-frontend/tailwind.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: {} + }, + plugins: [] +}; diff --git a/matera-frontend/tsconfig.json b/matera-frontend/tsconfig.json new file mode 100644 index 0000000..5eabbe2 --- /dev/null +++ b/matera-frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/matera-frontend/vite.config.ts b/matera-frontend/vite.config.ts new file mode 100644 index 0000000..f3a841e --- /dev/null +++ b/matera-frontend/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173 + } +}); diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml index 1f94a29..155f2fb 100644 --- a/pom.xml +++ b/pom.xml @@ -1,50 +1,39 @@ - - + 4.0.0 + org.springframework.boot spring-boot-starter-parent - 4.0.0 - + 3.3.5 + + com.desafiotecnico matera 0.0.1-SNAPSHOT matera - Demo project for Spring Boot - - - - - - - - - - - - - + Desafio técnico – Bank API + 17 + org.springframework.boot spring-boot-starter-data-jpa - - org.springframework.boot - spring-boot-starter-hateoas - + org.springframework.boot spring-boot-starter-validation + org.springframework.boot - spring-boot-starter-webmvc + spring-boot-starter-web @@ -53,39 +42,34 @@ runtime true + com.h2database h2 runtime + org.postgresql postgresql runtime + org.projectlombok lombok true + - org.springframework.boot - spring-boot-starter-data-jpa-test - test - - - org.springframework.boot - spring-boot-starter-hateoas-test - test - - - org.springframework.boot - spring-boot-starter-validation-test - test + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.5.0 + org.springframework.boot - spring-boot-starter-webmvc-test + spring-boot-starter-test test @@ -104,6 +88,7 @@ + org.springframework.boot spring-boot-maven-plugin @@ -118,5 +103,4 @@ - diff --git a/postman-collection/Matera.postman_collection.json b/postman-collection/Matera.postman_collection.json new file mode 100644 index 0000000..f8065a8 --- /dev/null +++ b/postman-collection/Matera.postman_collection.json @@ -0,0 +1,148 @@ +{ + "info": { + "_postman_id": "91577d9c-1f03-4296-8b31-5b2d96777502", + "name": "Matera", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "37957167" + }, + "item": [ + { + "name": "Create account", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"number\": \"12345-0\",\n \"initialBalance\": 1000.00\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/accounts", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "accounts" + ] + } + }, + "response": [] + }, + { + "name": "List all accounts", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"number\": \"12345-0\",\n \"initialBalance\": 1000.00\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/accounts", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "accounts" + ] + } + }, + "response": [] + }, + { + "name": "Make Transactions account", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"transactions\": [\n { \"type\": \"DEBIT\", \"amount\": 100.00 },\n { \"type\": \"CREDIT\", \"amount\": 50.00 }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/accounts/af83ba62-3e30-44b5-bf47-fa91cc9dc0a4/transactions", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "accounts", + "af83ba62-3e30-44b5-bf47-fa91cc9dc0a4", + "transactions" + ] + } + }, + "response": [] + }, + { + "name": "Get Balance from account by id", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/accounts/af83ba62-3e30-44b5-bf47-fa91cc9dc0a4/balance", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "accounts", + "af83ba62-3e30-44b5-bf47-fa91cc9dc0a4", + "balance" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/src/main/java/com/desafiotecnico/matera/account/api/AccountController.java b/src/main/java/com/desafiotecnico/matera/account/api/AccountController.java new file mode 100644 index 0000000..f6e5c7e --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/api/AccountController.java @@ -0,0 +1,118 @@ +package com.desafiotecnico.matera.account.api; + +import com.desafiotecnico.matera.account.domain.Account; +import com.desafiotecnico.matera.account.dto.BalanceResponse; +import com.desafiotecnico.matera.account.dto.CreateAccountRequest; +import com.desafiotecnico.matera.account.dto.TransactionBatchRequest; +import com.desafiotecnico.matera.account.service.AccountService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.List; + +@Tag( + name = "Accounts", + description = "Operações de contas bancárias: criação, consulta de saldo e lançamentos de débito/crédito." +) +@RestController +@RequestMapping("/api/accounts") +@RequiredArgsConstructor +public class AccountController { + private final AccountService accountService; + + @Operation( + summary = "Criar conta bancária", + description = "Cria uma nova conta bancária com número único e saldo inicial opcional." + ) + @ApiResponse( + responseCode = "201", + description = "Conta criada com sucesso", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BalanceResponse.class) + ) + ) + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateAccountRequest request) { + Account account = accountService.createAccount(request); + + BalanceResponse body = new BalanceResponse( + account.getId(), + account.getNumber(), + account.getBalance() + ); + + URI location = URI.create("/api/accounts/" + account.getId()); + + return ResponseEntity + .created(location) + .body(body); + } + + + @Operation( + summary = "Aplicar lançamentos em lote", + description = """ + Aplica um ou mais lançamentos de débito/crédito em uma conta específica. + A operação é realizada de forma transacional e thread-safe, garantindo consistência de saldo em cenários concorrentes. + """ + ) + @ApiResponse( + responseCode = "204", + description = "Lançamentos aplicados com sucesso" + ) + @ApiResponse( + responseCode = "404", + description = "Conta não encontrada", + content = @Content(schema = @Schema(implementation = com.desafiotecnico.matera.shared.error.ApiErrorResponse.class)) + ) + @ApiResponse( + responseCode = "422", + description = "Saldo insuficiente", + content = @Content(schema = @Schema(implementation = com.desafiotecnico.matera.shared.error.ApiErrorResponse.class)) + ) + @PostMapping("/{id}/transactions") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void applyTransactions( + @Parameter(description = "ID da conta (UUID)") + @PathVariable String id, + @Valid @RequestBody TransactionBatchRequest batch) { + accountService.applyTransactionsWithRetry(id, batch); + } + + @Operation( + summary = "Obter saldo atual", + description = "Retorna apenas o saldo atual da conta informada. Atalho para consultas rápidas." + ) + @GetMapping("/{id}/balance") + public BalanceResponse getBalance( + @Parameter(description = "ID da conta (UUID)") + @PathVariable String id) + { + return accountService.getBalance(id); + } + + @Operation( + summary = "Listar contas", + description = "Retorna todas as contas cadastradas, incluindo as contas seed." + ) + @ApiResponse( + responseCode = "200", + description = "Lista de contas retornada com sucesso" + ) + @GetMapping + public List listAccounts() { + return accountService.listAccounts(); + } +} diff --git a/src/main/java/com/desafiotecnico/matera/account/domain/Account.java b/src/main/java/com/desafiotecnico/matera/account/domain/Account.java new file mode 100644 index 0000000..41fd23c --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/domain/Account.java @@ -0,0 +1,71 @@ +package com.desafiotecnico.matera.account.domain; + +import com.desafiotecnico.matera.shared.exception.InsufficientBalanceException; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Entity +@Table(name = "accounts") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Account { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(nullable = false, unique = true) + private String number; + + @Column(nullable = false, precision = 19, scale = 2) + private BigDecimal balance; + + @Version + private Long version; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @Column(nullable = false) + private Instant updatedAt; + + @PrePersist + void prePersist() { + Instant now = Instant.now(); + createdAt = now; + updatedAt = now; + if (balance == null) { + balance = BigDecimal.ZERO; + } + } + + @PreUpdate + void preUpdate() { + updatedAt = Instant.now(); + } + + public void credit(BigDecimal amount) { + validateAmount(amount); + this.balance = this.balance.add(amount); + } + + public void debit(BigDecimal amount) { + validateAmount(amount); + if (this.balance.compareTo(amount) < 0) { + throw new InsufficientBalanceException("Saldo insuficiente."); + } + this.balance = this.balance.subtract(amount); + } + + private void validateAmount(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Valor deve ser maior que zero."); + } + } +} diff --git a/src/main/java/com/desafiotecnico/matera/account/domain/Transaction.java b/src/main/java/com/desafiotecnico/matera/account/domain/Transaction.java new file mode 100644 index 0000000..37428bc --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/domain/Transaction.java @@ -0,0 +1,40 @@ +package com.desafiotecnico.matera.account.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Entity +@Table(name = "transactions") +@Getter @Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Transaction { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "account_id") + private Account account; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TransactionType type; + + @Column(nullable = false, precision = 19, scale = 2) + private BigDecimal amount; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + public void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } +} diff --git a/src/main/java/com/desafiotecnico/matera/account/domain/TransactionType.java b/src/main/java/com/desafiotecnico/matera/account/domain/TransactionType.java new file mode 100644 index 0000000..34f001e --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/domain/TransactionType.java @@ -0,0 +1,5 @@ +package com.desafiotecnico.matera.account.domain; + +public enum TransactionType { + DEBIT, CREDIT +} diff --git a/src/main/java/com/desafiotecnico/matera/account/dto/BalanceResponse.java b/src/main/java/com/desafiotecnico/matera/account/dto/BalanceResponse.java new file mode 100644 index 0000000..671f02f --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/dto/BalanceResponse.java @@ -0,0 +1,11 @@ +package com.desafiotecnico.matera.account.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.math.BigDecimal; + +public record BalanceResponse ( + @JsonProperty("id") String accountId, + String number, + BigDecimal balance +) {} \ No newline at end of file diff --git a/src/main/java/com/desafiotecnico/matera/account/dto/CreateAccountRequest.java b/src/main/java/com/desafiotecnico/matera/account/dto/CreateAccountRequest.java new file mode 100644 index 0000000..65da1fb --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/dto/CreateAccountRequest.java @@ -0,0 +1,11 @@ +package com.desafiotecnico.matera.account.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; + +import java.math.BigDecimal; + +public record CreateAccountRequest ( + @NotBlank String number, + @PositiveOrZero BigDecimal initialBalance +){} diff --git a/src/main/java/com/desafiotecnico/matera/account/dto/TransactionBatchRequest.java b/src/main/java/com/desafiotecnico/matera/account/dto/TransactionBatchRequest.java new file mode 100644 index 0000000..e605165 --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/dto/TransactionBatchRequest.java @@ -0,0 +1,10 @@ +package com.desafiotecnico.matera.account.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +public record TransactionBatchRequest ( + @NotEmpty List<@Valid TransactionRequest> transactions +) {} \ No newline at end of file diff --git a/src/main/java/com/desafiotecnico/matera/account/dto/TransactionRequest.java b/src/main/java/com/desafiotecnico/matera/account/dto/TransactionRequest.java new file mode 100644 index 0000000..e4dc7cd --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/dto/TransactionRequest.java @@ -0,0 +1,12 @@ +package com.desafiotecnico.matera.account.dto; + +import com.desafiotecnico.matera.account.domain.TransactionType; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.math.BigDecimal; + +public record TransactionRequest ( + @NotNull TransactionType type, + @NotNull @Positive BigDecimal amount +) {} diff --git a/src/main/java/com/desafiotecnico/matera/account/repository/AccountRepository.java b/src/main/java/com/desafiotecnico/matera/account/repository/AccountRepository.java new file mode 100644 index 0000000..ce244fe --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/repository/AccountRepository.java @@ -0,0 +1,17 @@ +package com.desafiotecnico.matera.account.repository; + +import com.desafiotecnico.matera.account.domain.Account; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface AccountRepository extends JpaRepository { + Optional findByNumber(String number); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select a from Account a where a.id = :id") + Optional findByIdForUpdate(String id); +} \ No newline at end of file diff --git a/src/main/java/com/desafiotecnico/matera/account/repository/TransactionRepository.java b/src/main/java/com/desafiotecnico/matera/account/repository/TransactionRepository.java new file mode 100644 index 0000000..59a04ed --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/repository/TransactionRepository.java @@ -0,0 +1,10 @@ +package com.desafiotecnico.matera.account.repository; + +import com.desafiotecnico.matera.account.domain.Transaction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TransactionRepository extends JpaRepository { + List findByAccountIdOrderByCreatedAtDesc(String accountId); +} \ No newline at end of file diff --git a/src/main/java/com/desafiotecnico/matera/account/service/AccountService.java b/src/main/java/com/desafiotecnico/matera/account/service/AccountService.java new file mode 100644 index 0000000..383aac3 --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/account/service/AccountService.java @@ -0,0 +1,94 @@ +package com.desafiotecnico.matera.account.service; + +import com.desafiotecnico.matera.account.domain.Account; +import com.desafiotecnico.matera.account.domain.Transaction; +import com.desafiotecnico.matera.account.domain.TransactionType; +import com.desafiotecnico.matera.account.dto.BalanceResponse; +import com.desafiotecnico.matera.account.dto.CreateAccountRequest; +import com.desafiotecnico.matera.account.dto.TransactionBatchRequest; +import com.desafiotecnico.matera.account.dto.TransactionRequest; +import com.desafiotecnico.matera.account.repository.AccountRepository; +import com.desafiotecnico.matera.account.repository.TransactionRepository; +import com.desafiotecnico.matera.shared.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AccountService { + + private final AccountRepository accountRepository; + + private final TransactionRepository transactionRepository; + + @Transactional + public Account createAccount(CreateAccountRequest request) { + Account account = Account.builder() + .number(request.number()) + .balance( + request.initialBalance() != null ? request.initialBalance() : BigDecimal.ZERO + ) + .build(); + return accountRepository.save(account); + } + + @Transactional + public void applyTransactions(String accountId, TransactionBatchRequest batch) { + Account account = accountRepository.findByIdForUpdate(accountId) + .orElseThrow(() -> new NotFoundException("Conta não encontrada")); + + for (TransactionRequest tr : batch.transactions()) { + if (tr.type() == TransactionType.CREDIT) { + account.credit(tr.amount()); + } else if (tr.type() == TransactionType.DEBIT) { + account.debit(tr.amount()); + } + + Transaction tx = Transaction.builder() + .account(account) + .type(tr.type()) + .amount(tr.amount()) + .build(); + transactionRepository.save(tx); + } + + accountRepository.save(account); + } + + + @Transactional(readOnly = true) + public BalanceResponse getBalance(String accountId) { + Account account = accountRepository.findById(accountId) + .orElseThrow(() -> new NotFoundException("Conta não encontrada")); + + return new BalanceResponse(account.getId(), account.getNumber(), account.getBalance()); + } + + @Transactional + public void applyTransactionsWithRetry(String accountId, TransactionBatchRequest batch) { + applyTransactions(accountId, batch); + } + + @Transactional + protected void doApplyTransactions(String accountId, TransactionBatchRequest batch) { + applyTransactions(accountId, batch); + } + + @Transactional(readOnly = true) + public List listAccounts() { + return accountRepository.findAll().stream() + .map(account -> new BalanceResponse( + account.getId(), + account.getNumber(), + account.getBalance() + )) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/desafiotecnico/matera/config/CorsConfig.java b/src/main/java/com/desafiotecnico/matera/config/CorsConfig.java new file mode 100644 index 0000000..4006c87 --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/config/CorsConfig.java @@ -0,0 +1,27 @@ +package com.desafiotecnico.matera.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig { + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins( + "http://localhost:5173", + "http://localhost:8081" + ) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(false); + } + }; + } +} diff --git a/src/main/java/com/desafiotecnico/matera/config/DataSeeder.java b/src/main/java/com/desafiotecnico/matera/config/DataSeeder.java new file mode 100644 index 0000000..1ab8ea4 --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/config/DataSeeder.java @@ -0,0 +1,68 @@ +package com.desafiotecnico.matera.config; + +import com.desafiotecnico.matera.account.domain.Account; +import com.desafiotecnico.matera.account.domain.Transaction; +import com.desafiotecnico.matera.account.domain.TransactionType; +import com.desafiotecnico.matera.account.repository.AccountRepository; +import com.desafiotecnico.matera.account.repository.TransactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@Configuration +@RequiredArgsConstructor +public class DataSeeder { + private final AccountRepository accountRepository; + private final TransactionRepository transactionRepository; + + @Bean + @Transactional + public org.springframework.boot.CommandLineRunner seedData() { + return args -> { + if (accountRepository.count() > 0) { + return; + } + + Account acc1 = Account.builder() + .number("ACC-1001") + .balance(BigDecimal.valueOf(1000.00)) + .build(); + + Account acc2 = Account.builder() + .number("ACC-2001") + .balance(BigDecimal.valueOf(500.00)) + .build(); + + Account acc3 = Account.builder() + .number("ACC-3001") + .balance(BigDecimal.ZERO) + .build(); + + acc1 = accountRepository.save(acc1); + acc2 = accountRepository.save(acc2); + acc3 = accountRepository.save(acc3); + + Transaction t1 = Transaction.builder() + .account(acc1) + .type(TransactionType.CREDIT) + .amount(BigDecimal.valueOf(200.00)) + .build(); + + Transaction t2 = Transaction.builder() + .account(acc1) + .type(TransactionType.DEBIT) + .amount(BigDecimal.valueOf(50.00)) + .build(); + + acc1.credit(t1.getAmount()); + acc1.debit(t2.getAmount()); + + transactionRepository.save(t1); + transactionRepository.save(t2); + accountRepository.save(acc1); + }; + } +} diff --git a/src/main/java/com/desafiotecnico/matera/config/OpenApiConfig.java b/src/main/java/com/desafiotecnico/matera/config/OpenApiConfig.java new file mode 100644 index 0000000..5c66933 --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/config/OpenApiConfig.java @@ -0,0 +1,25 @@ +package com.desafiotecnico.matera.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "Bank API - Desafio Técnico Matera", + version = "1.0.0", + description = "API RESTful para lançamentos bancários (débito/crédito), com controle de concorrência e histórico de transações.", + contact = @Contact( + name = "Yago Martins", + email = "yagolopesmartins777@gmail.com" + ), + license = @License( + name = "Uso exclusivo para avaliação técnica" + ) + ) +) +public class OpenApiConfig { +} diff --git a/src/main/java/com/desafiotecnico/matera/shared/error/ApiErrorResponse.java b/src/main/java/com/desafiotecnico/matera/shared/error/ApiErrorResponse.java new file mode 100644 index 0000000..24ca429 --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/shared/error/ApiErrorResponse.java @@ -0,0 +1,4 @@ +package com.desafiotecnico.matera.shared.error; + +public record ApiErrorResponse(String code, String message) { +} diff --git a/src/main/java/com/desafiotecnico/matera/shared/error/ErrorResponse.java b/src/main/java/com/desafiotecnico/matera/shared/error/ErrorResponse.java new file mode 100644 index 0000000..b3b7c43 --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/shared/error/ErrorResponse.java @@ -0,0 +1,3 @@ +package com.desafiotecnico.matera.shared.error; + +public record ErrorResponse (String code, String message) {} \ No newline at end of file diff --git a/src/main/java/com/desafiotecnico/matera/shared/exception/ApiExceptionHandler.java b/src/main/java/com/desafiotecnico/matera/shared/exception/ApiExceptionHandler.java new file mode 100644 index 0000000..9afe63c --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/shared/exception/ApiExceptionHandler.java @@ -0,0 +1,60 @@ +package com.desafiotecnico.matera.shared.exception; + +import com.desafiotecnico.matera.shared.error.ApiErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ApiErrorResponse handleNotFound(NotFoundException ex) { + return new ApiErrorResponse("NOT_FOUND", ex.getMessage()); + } + + @ExceptionHandler(InsufficientBalanceException.class) + @ResponseStatus(code = HttpStatus.UNPROCESSABLE_ENTITY) + public ApiErrorResponse handleInsufficient(InsufficientBalanceException ex) { + return new ApiErrorResponse("INSUFFICIENT_BALANCE", ex.getMessage()); + } + + // genérico para outras BusinessException que você criar no futuro + @ExceptionHandler(BusinessException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiErrorResponse handleBusiness(BusinessException ex) { + return new ApiErrorResponse("BUSINESS_ERROR", ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiErrorResponse handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> fe.getField() + " " + fe.getDefaultMessage()) + .collect(Collectors.joining(", ")); + return new ApiErrorResponse("VALIDATION_ERROR", message); + } + + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public ApiErrorResponse handleOptimisticLock(ObjectOptimisticLockingFailureException ex) { + return new ApiErrorResponse( + "CONCURRENT_MODIFICATION", + "A conta foi modificada por outra transação. Tente novamente." + ); + } + + // fallback pra qualquer erro inesperado + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiErrorResponse handleUnexpected(Exception ex) { + // aqui em prod você logaria o stack trace (log.error) + return new ApiErrorResponse("INTERNAL_ERROR", "Ocorreu um erro inesperado."); + } +} diff --git a/src/main/java/com/desafiotecnico/matera/shared/exception/BusinessException.java b/src/main/java/com/desafiotecnico/matera/shared/exception/BusinessException.java new file mode 100644 index 0000000..e4b4b22 --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/shared/exception/BusinessException.java @@ -0,0 +1,16 @@ +package com.desafiotecnico.matera.shared.exception; + +/** + * Exceção base para erros de regra de negócio. + * Pode ser usada diretamente ou estendida por exceções mais específicas. + */ +public class BusinessException extends RuntimeException { + + public BusinessException(String message) { + super(message); + } + + public BusinessException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/desafiotecnico/matera/shared/exception/InsufficientBalanceException.java b/src/main/java/com/desafiotecnico/matera/shared/exception/InsufficientBalanceException.java new file mode 100644 index 0000000..b5d1aac --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/shared/exception/InsufficientBalanceException.java @@ -0,0 +1,8 @@ +package com.desafiotecnico.matera.shared.exception; + +public class InsufficientBalanceException extends BusinessException { + + public InsufficientBalanceException(String message) { + super(message); + } +} diff --git a/src/main/java/com/desafiotecnico/matera/shared/exception/NotFoundException.java b/src/main/java/com/desafiotecnico/matera/shared/exception/NotFoundException.java new file mode 100644 index 0000000..81cb4b0 --- /dev/null +++ b/src/main/java/com/desafiotecnico/matera/shared/exception/NotFoundException.java @@ -0,0 +1,8 @@ +package com.desafiotecnico.matera.shared.exception; + +public class NotFoundException extends BusinessException { + + public NotFoundException(String message) { + super(message); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8756be7..0ebba87 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,11 @@ spring.application.name=matera +spring.datasource.url=jdbc:postgresql://localhost:5432/bank +spring.datasource.username=bankuser +spring.datasource.password=bankpass + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +spring.jpa.properties.hibernate.jdbc.time_zone=UTC + diff --git a/src/test/java/com/desafiotecnico/matera/MateraApplicationTests.java b/src/test/java/com/desafiotecnico/matera/MateraApplicationTests.java index ba671ae..ec90a94 100644 --- a/src/test/java/com/desafiotecnico/matera/MateraApplicationTests.java +++ b/src/test/java/com/desafiotecnico/matera/MateraApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class MateraApplicationTests { @Test diff --git a/src/test/java/com/desafiotecnico/matera/account/api/AccountControllerTest.java b/src/test/java/com/desafiotecnico/matera/account/api/AccountControllerTest.java new file mode 100644 index 0000000..c8dd868 --- /dev/null +++ b/src/test/java/com/desafiotecnico/matera/account/api/AccountControllerTest.java @@ -0,0 +1,71 @@ +package com.desafiotecnico.matera.account.api; + +import com.desafiotecnico.matera.account.domain.Account; +import com.desafiotecnico.matera.account.dto.CreateAccountRequest; +import com.desafiotecnico.matera.account.service.AccountService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; + +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(AccountController.class) +@ActiveProfiles("test") +class AccountControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountService accountService; + + @Test + void should_create_account_and_return_201() throws Exception { + Account account = Account.builder() + .id("id-123") + .number("12345-0") + .balance(BigDecimal.valueOf(1000L)) + .build(); + + Mockito.when(accountService.createAccount(any(CreateAccountRequest.class))) + .thenReturn(account); + + mockMvc.perform(post("/api/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "number": "12345-0", + "initialBalance": 1000.00 + } + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/accounts/id-123")) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.id", is("id-123"))) + .andExpect(jsonPath("$.number", is("12345-0"))) + .andExpect(jsonPath("$.balance").value(1000.0)); + } + + @Test + void should_return_400_when_payload_is_invalid() throws Exception { + mockMvc.perform(post("/api/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "number": "", + "initialBalance": -10 + } + """)) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/desafiotecnico/matera/account/service/AccountServiceTest.java b/src/test/java/com/desafiotecnico/matera/account/service/AccountServiceTest.java new file mode 100644 index 0000000..9cd0dfa --- /dev/null +++ b/src/test/java/com/desafiotecnico/matera/account/service/AccountServiceTest.java @@ -0,0 +1,165 @@ +package com.desafiotecnico.matera.account.service; + +import com.desafiotecnico.matera.account.domain.Account; +import com.desafiotecnico.matera.account.domain.TransactionType; +import com.desafiotecnico.matera.account.dto.BalanceResponse; +import com.desafiotecnico.matera.account.dto.CreateAccountRequest; +import com.desafiotecnico.matera.account.dto.TransactionBatchRequest; +import com.desafiotecnico.matera.account.dto.TransactionRequest; +import com.desafiotecnico.matera.account.repository.AccountRepository; +import com.desafiotecnico.matera.account.repository.TransactionRepository; +import com.desafiotecnico.matera.shared.exception.InsufficientBalanceException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +@SpringBootTest +@ActiveProfiles("test") +class AccountServiceTest { + + @Autowired + private AccountService accountService; + + @Autowired + private AccountRepository accountRepository; + + @Autowired + private TransactionRepository transactionRepository; + + @AfterEach + void cleanup() { + transactionRepository.deleteAllInBatch(); + accountRepository.deleteAllInBatch(); + } + + @Test + void should_apply_credit_and_debit_in_batch() { + Account account = accountService.createAccount( + new CreateAccountRequest("9876-0", BigDecimal.valueOf(1000L)) + ); + + TransactionBatchRequest batch = new TransactionBatchRequest( + List.of( + new TransactionRequest(TransactionType.DEBIT, BigDecimal.valueOf(100L)), + new TransactionRequest(TransactionType.CREDIT, BigDecimal.valueOf(50L)) + ) + ); + + accountService.applyTransactionsWithRetry(account.getId(), batch); + + BalanceResponse balance = accountService.getBalance(account.getId()); + assertThat(balance.balance()).isEqualByComparingTo("950.00"); + } + + @Test + void should_throw_when_insufficient_balance() { + Account account = accountService.createAccount( + new CreateAccountRequest("9999-0", BigDecimal.valueOf(50L)) + ); + + TransactionBatchRequest batch = new TransactionBatchRequest( + List.of( + new TransactionRequest(TransactionType.DEBIT, BigDecimal.valueOf(100L)) + ) + ); + + assertThrows(InsufficientBalanceException.class, + () -> accountService.applyTransactionsWithRetry(account.getId(), batch)); + } + + @Test + void should_handle_concurrent_transactions_on_same_account() throws Exception { + Account account = accountService.createAccount( + new CreateAccountRequest("ACC-CONCURRENT", BigDecimal.valueOf(1000L)) + ); + + String accountId = account.getId(); + + final int threads = 10; + final int operationsPerThread = 20; + + ExecutorService executor = Executors.newFixedThreadPool(threads); + List> tasks = new ArrayList<>(); + + for (int i = 0; i < threads; i++) { + tasks.add(() -> { + for (int j = 0; j < operationsPerThread; j++) { + TransactionBatchRequest batch = new TransactionBatchRequest( + List.of( + new TransactionRequest(TransactionType.CREDIT, BigDecimal.TEN), + new TransactionRequest(TransactionType.DEBIT, BigDecimal.valueOf(5L)) + ) + ); + accountService.applyTransactionsWithRetry(accountId, batch); + } + return null; + }); + } + + List> futures = executor.invokeAll(tasks); + executor.shutdown(); + boolean finished = executor.awaitTermination(1, TimeUnit.MINUTES); + if (!finished) { + executor.shutdownNow(); + fail("Executor did not finish within the timeout"); + } + + for (Future f : futures) { + f.get(); // Propaga qualquer exceção das tasks + } + + int totalBatches = threads * operationsPerThread; + BigDecimal expectedDelta = BigDecimal.valueOf(totalBatches * 5L); + BigDecimal expected = BigDecimal.valueOf(1000L).add(expectedDelta); + + BalanceResponse balance = accountService.getBalance(accountId); + assertEquals(0, balance.balance().compareTo(expected)); + } + + @Test + void concurrent_batches_keep_balance_consistent() throws Exception { + Account acc = accountService.createAccount( + new CreateAccountRequest("ACC-CONC-1", new BigDecimal("1000.00")) + ); + + TransactionBatchRequest batch = new TransactionBatchRequest( + List.of( + new TransactionRequest(TransactionType.CREDIT, new BigDecimal("10.00")), + new TransactionRequest(TransactionType.DEBIT, new BigDecimal("5.00")) + ) + ); + + int threads = 10; + int batchesPerThread = 10; + + ExecutorService executor = Executors.newFixedThreadPool(threads); + for (int i = 0; i < threads * batchesPerThread; i++) { + executor.submit(() -> accountService.applyTransactionsWithRetry(acc.getId(), batch)); + } + executor.shutdown(); + boolean finished = executor.awaitTermination(30, TimeUnit.SECONDS); + if (!finished) { + executor.shutdownNow(); + fail("Executor did not finish within the timeout"); + } + + Account updated = accountRepository.findById(acc.getId()).orElseThrow(); + + BigDecimal expected = new BigDecimal("1000.00") + .add(new BigDecimal("5.00").multiply(BigDecimal.valueOf(threads * batchesPerThread))); + + assertThat(updated.getBalance()).isEqualByComparingTo(expected); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..6968ea1 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,14 @@ +spring.datasource.url=jdbc:h2:mem:banktest;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + +spring.test.database.replace=none +spring.sql.init.mode=never + +springdoc.api-docs.enabled=false +springdoc.swagger-ui.enabled=false +