├── .dockerignore ├── .env.example ├── .env.local ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── dependabot.yml └── workflows │ └── go.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── Test └── integration │ ├── README.md │ ├── features │ ├── auth.feature │ ├── medicine.feature │ └── users.feature │ ├── main_test.go │ └── steps.go ├── docker-compose.yml ├── docs ├── API_DOCUMENTATION.md ├── DEPLOYMENT_GUIDE.md ├── README.md ├── README_CLEAN_ARCHITECTURE.md └── SEARCH_ENDPOINTS.md ├── go.mod ├── go.sum ├── lefthook.yml ├── main.go ├── scripts └── run-integration-test.bash └── src ├── application └── usecases │ ├── auth │ ├── auth.go │ └── auth_test.go │ ├── medicine │ ├── medicine.go │ └── medicine_test.go │ └── user │ ├── user.go │ └── user_test.go ├── domain ├── Types.go ├── errors │ ├── Errors.go │ ├── Errors_test.go │ └── Gorm.go ├── medicine │ ├── Medicine.go │ └── medicine_test.go └── user │ ├── user.go │ └── user_test.go └── infrastructure ├── di ├── application_context.go └── application_context_test.go ├── logger └── logger.go ├── repository └── psql │ ├── medicine │ ├── medicine.go │ └── medicine_test.go │ ├── psql_repository.go │ └── user │ ├── user.go │ └── user_test.go ├── rest ├── controllers │ ├── BindTools.go │ ├── BindTools_test.go │ ├── Utils.go │ ├── auth │ │ ├── Auth.go │ │ ├── Auth_test.go │ │ └── Structures.go │ ├── medicine │ │ ├── Medicines.go │ │ ├── Medicines_test.go │ │ └── Validation.go │ └── user │ │ ├── User.go │ │ ├── User_test.go │ │ └── Validation.go ├── middlewares │ ├── Headers.go │ ├── Headers_test.go │ ├── Interceptor.go │ ├── Interceptor_test.go │ ├── RequiresLogin.go │ ├── RequiresLogin_test.go │ ├── errorHandler.go │ └── errorHandler_test.go └── routes │ ├── auth.go │ ├── medicine.go │ ├── routes.go │ └── user.go └── security ├── jwt_service.go └── jwt_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .git 3 | .gitignore -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # PostgreSQL InitialConfiguration 2 | POSTGRES_DB=boilerplate_go 3 | POSTGRES_USER=postgres 4 | POSTGRES_PASSWORD=devPassword123 5 | 6 | # Database Configuration for Application 7 | DB_HOST=postgres 8 | DB_PORT=5432 9 | DB_USER=postgres 10 | DB_PASSWORD=devPassword123 11 | DB_NAME=boilerplate_go 12 | DB_SSLMODE=disable 13 | 14 | # Server Configuration 15 | SERVER_PORT=8080 16 | 17 | # Database Connection Pool Configuration 18 | DB_MAX_IDLE_CONNS=10 19 | DB_MAX_OPEN_CONNS=50 20 | DB_CONN_MAX_LIFETIME=300 21 | 22 | # JWT Configuration 23 | JWT_ACCESS_SECRET_KEY=devAccessSecretKey123456789 24 | JWT_ACCESS_TIME_MINUTE=15 25 | JWT_REFRESH_SECRET_KEY=devRefreshSecretKey123456789 26 | JWT_REFRESH_TIME_HOUR=168 27 | JWT_ISSUER=microservice 28 | 29 | # Initial User Configuration 30 | START_USER_EMAIL=gbrayhan@gmail.com 31 | START_USER_PW=qweqwe 32 | 33 | # Optional External Services 34 | IMGUR_CLIENT_ID=yourImgurClientId 35 | WKHTMLTOPDF_BIN=/usr/local/bin/wkhtmltopdf 36 | -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | # PostgreSQL InitialConfiguration 2 | POSTGRES_DB=boilerplate_go 3 | POSTGRES_USER=postgres 4 | POSTGRES_PASSWORD=devPassword123 5 | 6 | # Database Configuration for Application 7 | DB_HOST=localhost 8 | DB_PORT=5432 9 | DB_USER=postgres 10 | DB_PASSWORD=devPassword123 11 | DB_NAME=boilerplate_go 12 | DB_SSLMODE=disable 13 | 14 | # Server Configuration 15 | SERVER_PORT=8080 16 | 17 | # Database Connection Pool Configuration 18 | DB_MAX_IDLE_CONNS=10 19 | DB_MAX_OPEN_CONNS=50 20 | DB_CONN_MAX_LIFETIME=300 21 | 22 | # JWT Configuration 23 | JWT_ACCESS_SECRET_KEY=devAccessSecretKey123456789 24 | JWT_ACCESS_TIME_MINUTE=15 25 | JWT_REFRESH_SECRET_KEY=devRefreshSecretKey123456789 26 | JWT_REFRESH_TIME_HOUR=168 27 | JWT_ISSUER=microservice 28 | 29 | # Initial User Configuration 30 | START_USER_EMAIL=gbrayhan@gmail.com 31 | START_USER_PW=qweqwe 32 | 33 | # Optional External Services 34 | IMGUR_CLIENT_ID=yourImgurClientId 35 | WKHTMLTOPDF_BIN=/usr/local/bin/wkhtmltopdf 36 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [gbrayhan] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: BossonH # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Desktop (please complete the following information):** 28 | 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Smartphone (please complete the following information):** 34 | 35 | - Device: [e.g. iPhone6] 36 | - OS: [e.g. iOS8.1] 37 | - Browser [e.g. stock browser, safari] 38 | - Version [e.g. 22] 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | vendor: true 10 | directory: "/" # Location of package manifests 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.24 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE Folders and Files 2 | .idea 3 | .DS_Store 4 | .env 5 | 6 | 7 | *.txt 8 | *.log 9 | 10 | microservices 11 | config.json 12 | 13 | microservices-go 14 | app-microservice 15 | main 16 | dev-aceso 17 | 18 | 19 | coverage.html 20 | coverage.out 21 | coverage_filtered.out 22 | 23 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at gbrayhan@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing Guidelines 2 | Thank you for considering contributing to our project. To ensure that all contributors have a positive experience, we have established some guidelines that you should follow. 3 | 4 | Before you begin contributing, make sure to read our Code of Conduct to understand how we expect community members to interact with each other. 5 | 6 | ### How to Contribute. 7 | 1. Fork the repository on GitHub. 8 | 2. Create a branch in your fork with a descriptive name indicating the change you are making. 9 | 3. Make the changes in your branch. 10 | 4. Make sure your changes follow clean architecture patterns and coding best practices in Go. 11 | 5. Run tests to ensure you haven't introduced any errors. 12 | 6. Create a pull request from your branch to the main branch of the repository. 13 | 7. Wait for the team to review your pull request. If changes are needed, make sure to make them before submitting a new review. 14 | 8. Once your pull request is approved, the team will merge it into the main branch. 15 | 16 | ### How to Report an Issue. 17 | 18 | If you find a bug or issue in the project, please follow these steps to report it: 19 | 20 | 1. Open an issue on the repository describing the problem as detailed as possible. 21 | 2. Provide an example of the incorrect behavior and how it can be reproduced. 22 | 3. Provide information about the environment in which you are running the code, including the version of Go you are using. 23 | 4. If you have a solution for the problem, please submit a pull request following the guidelines established above. 24 | 25 | ### Pull Request Guidelines 26 | To ensure that pull requests integrate smoothly, please follow these guidelines: 27 | 28 | 1. Follow the guidelines established in our Code of Conduct. 29 | 2. Make sure your code follows Go's coding conventions, including formatting and style. 30 | 3. Make sure your code follows clean architecture patterns and coding best practices in Go. 31 | 4. Make sure your changes do not break any existing tests, and you have added tests for any new functionality. 32 | 5. Create a detailed description of the changes you made and why you made them. 33 | 34 | 35 | Thank you for contributing to our project. Your time and effort are greatly appreciated. 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS builder 2 | 3 | WORKDIR /srv/go-app 4 | 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | 8 | COPY . . 9 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ 10 | go build -a -installsuffix cgo -o microservice . 11 | 12 | FROM alpine:3.20 13 | 14 | WORKDIR /srv/go-app 15 | COPY --from=builder /srv/go-app/microservice . 16 | 17 | # Install curl for healthcheck 18 | RUN apk add --no-cache curl 19 | 20 | # Create non-root user 21 | RUN addgroup -g 1001 -S appgroup && \ 22 | adduser -u 1001 -S appuser -G appgroup 23 | 24 | USER appuser:appgroup 25 | 26 | EXPOSE 8080 27 | 28 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 29 | CMD curl -f http://localhost:8080/v1/user/ || exit 1 30 | 31 | CMD ["./microservice"] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (ctx) 2020 gbrayhan@gmail.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 1.1.x | :white_check_mark: | 11 | | 1.0.x | :white_check_mark: | 12 | | < 0.1 | :x: | 13 | 14 | ## Reporting a Vulnerability 15 | 16 | Use this section to tell people how to report a vulnerability. 17 | 18 | Tell them where to go, how often they can expect to get an update on a 19 | reported vulnerability, what to expect if the vulnerability is accepted or 20 | declined, etc. 21 | -------------------------------------------------------------------------------- /Test/integration/README.md: -------------------------------------------------------------------------------- 1 | # Tests de Integración 2 | 3 | Este directorio contiene los tests de integración para la API de ia-boilerplate-go, implementados usando Cucumber/Gherkin con el framework Godog. 4 | 5 | ## Filosofías del Framework de Testing 6 | 7 | ### 1. **Gestión Autónoma de Recursos** 8 | - Todos los recursos creados durante los tests son automáticamente rastreados 9 | - Limpieza automática al final de cada escenario 10 | - Prevención de contaminación entre tests 11 | 12 | ### 2. **Autenticación Automática** 13 | - Login automático al inicio de cada escenario 14 | - Tokens de acceso manejados globalmente 15 | - Headers de autorización agregados automáticamente 16 | 17 | ### 3. **Variables Dinámicas** 18 | - Generación de valores únicos para evitar conflictos 19 | - Sustitución de variables en URLs y payloads 20 | - Persistencia de valores entre pasos del escenario 21 | 22 | ### 4. **Validación Robusta** 23 | - Verificación de códigos de estado HTTP 24 | - Validación de estructura JSON de respuestas 25 | - Manejo de errores y casos edge 26 | 27 | ## Estructura de Archivos 28 | 29 | ``` 30 | Test/integration/ 31 | ├── main_test.go # Configuración principal de tests 32 | ├── steps.go # Implementación de pasos Gherkin 33 | ├── README.md # Este archivo 34 | └── features/ # Archivos de features Gherkin 35 | ├── auth.feature # Tests de autenticación 36 | ├── users.feature # Tests de usuarios, roles y dispositivos 37 | ├── medicine.feature # Tests de medicamentos 38 | ├── icd-cie.feature # Tests de códigos ICD-CIE 39 | ├── device-info.feature # Tests de información de dispositivos 40 | └── error-handling.feature # Tests de manejo de errores 41 | ``` 42 | 43 | ## Archivos de Features 44 | 45 | ### 1. **auth.feature** 46 | Tests de autenticación y autorización: 47 | - Login con credenciales válidas/inválidas 48 | - Refresh de tokens 49 | - Acceso a endpoints protegidos sin autenticación 50 | 51 | ### 2. **users.feature** 52 | Tests completos de gestión de usuarios: 53 | - CRUD de roles de usuario 54 | - CRUD de usuarios 55 | - CRUD de dispositivos asociados a usuarios 56 | - Búsquedas paginadas y por propiedades 57 | 58 | ### 3. **medicine.feature** 59 | Tests de gestión de medicamentos: 60 | - CRUD de medicamentos 61 | - Validación de códigos EAN únicos 62 | - Búsquedas avanzadas 63 | - Manejo de campos requeridos 64 | 65 | ### 4. **icd-cie.feature** 66 | Tests de códigos ICD-CIE: 67 | - CRUD de registros ICD-CIE 68 | - Búsquedas con filtros múltiples 69 | - Validación de propiedades de búsqueda 70 | - Paginación y casos edge 71 | 72 | ### 5. **device-info.feature** 73 | Tests de información de dispositivos: 74 | - Endpoint de información de dispositivo 75 | - Health check autenticado 76 | - Verificación de middleware de dispositivos 77 | 78 | ### 6. **error-handling.feature** 79 | Tests de manejo de errores: 80 | - Casos de autenticación fallida 81 | - IDs inválidos 82 | - Campos requeridos faltantes 83 | - Payloads JSON malformados 84 | - Casos edge de paginación 85 | 86 | ## Ejecución de Tests 87 | 88 | ### Opción 1: Script Automatizado (Recomendado) 89 | 90 | ```bash 91 | # Ejecutar todos los tests 92 | ./scripts/run-all-integration-tests.bash 93 | 94 | # Ejecutar tests específicos 95 | ./scripts/run-all-integration-tests.bash -f auth.feature 96 | ./scripts/run-all-integration-tests.bash -f users.feature 97 | 98 | # Ejecutar con Docker 99 | ./scripts/run-all-integration-tests.bash -d -v 100 | 101 | # Ejecutar con tags específicos 102 | ./scripts/run-all-integration-tests.bash -t @smoke 103 | 104 | # Modo verbose 105 | ./scripts/run-all-integration-tests.bash -v 106 | ``` 107 | 108 | ### Opción 2: Comando Directo 109 | 110 | ```bash 111 | # Ejecutar todos los tests 112 | go test -tags=integration ./Test/integration/... 113 | 114 | # Ejecutar con verbose 115 | go test -v -tags=integration ./Test/integration/... 116 | 117 | # Ejecutar feature específico 118 | INTEGRATION_FEATURE_FILE=auth.feature go test -tags=integration ./Test/integration/... 119 | 120 | # Ejecutar con tags específicos 121 | INTEGRATION_SCENARIO_TAGS=@smoke go test -tags=integration ./Test/integration/... 122 | ``` 123 | 124 | ### Opción 3: Docker Compose 125 | 126 | ```bash 127 | # Ejecutar tests con Docker 128 | docker-compose run --rm app go test -tags=integration ./Test/integration/... 129 | 130 | # Ejecutar con verbose 131 | docker-compose run --rm app go test -v -tags=integration ./Test/integration/... 132 | ``` 133 | 134 | ## Variables de Entorno 135 | 136 | | Variable | Descripción | Ejemplo | 137 | |----------|-------------|---------| 138 | | `INTEGRATION_FEATURE_FILE` | Ejecutar solo un archivo de feature | `auth.feature` | 139 | | `INTEGRATION_SCENARIO_TAGS` | Ejecutar solo escenarios con tags específicos | `@smoke` | 140 | | `INTEGRATION_TEST_MODE` | Modo de testing activado | `true` | 141 | 142 | ## Estructura de un Escenario 143 | 144 | ```gherkin 145 | Scenario: TC01 - Create a new user successfully 146 | Given I generate a unique alias as "newUserUsername" 147 | And I generate a unique alias as "newUserEmail" 148 | When I send a POST request to "/api/users" with body: 149 | """ 150 | { 151 | "username": "${newUserUsername}", 152 | "email": "${newUserEmail}@test.com", 153 | "password": "securePassword123", 154 | "roleId": 1, 155 | "enabled": true 156 | } 157 | """ 158 | Then the response code should be 201 159 | And the JSON response should contain key "id" 160 | And I save the JSON response key "id" as "userID" 161 | ``` 162 | 163 | ## Pasos Disponibles 164 | 165 | ### Pasos Given (Configuración) 166 | - `I generate a unique alias as "varName"` 167 | - `I generate a unique EAN code as "varName"` 168 | - `I clear the authentication token` 169 | - `I am authenticated as a user` 170 | 171 | ### Pasos When (Acciones) 172 | - `I send a GET request to "path"` 173 | - `I send a POST request to "path" with body:` 174 | - `I send a PUT request to "path" with body:` 175 | - `I send a DELETE request to "path"` 176 | 177 | ### Pasos Then (Validaciones) 178 | - `the response code should be 200` 179 | - `the JSON response should contain key "keyName"` 180 | - `the JSON response should contain "field": "value"` 181 | - `the JSON response should contain error "error": "message"` 182 | - `I save the JSON response key "key" as "varName"` 183 | 184 | ## Gestión de Recursos 185 | 186 | ### Creación Automática 187 | Los recursos creados durante los tests son automáticamente rastreados: 188 | 189 | ```go 190 | // En steps.go 191 | func trackResource(path string) { 192 | // Rastrea recursos para limpieza posterior 193 | } 194 | ``` 195 | 196 | ### Limpieza Automática 197 | Al final de cada escenario, todos los recursos creados son eliminados: 198 | 199 | ```go 200 | // En steps.go 201 | func InitializeScenario(ctx *godog.ScenarioContext) { 202 | // Setup y teardown automático 203 | } 204 | ``` 205 | 206 | ## Debugging 207 | 208 | ### Modo Verbose 209 | ```bash 210 | go test -v -tags=integration ./Test/integration/... 211 | ``` 212 | 213 | ### Logs Detallados 214 | Los tests incluyen logs detallados que muestran: 215 | - URLs de requests 216 | - Headers enviados 217 | - Códigos de respuesta 218 | - Cuerpo de respuestas 219 | - Variables generadas 220 | 221 | ### Variables de Debug 222 | ```bash 223 | # Habilitar logs de debug 224 | export DEBUG=true 225 | go test -tags=integration ./Test/integration/... 226 | ``` 227 | 228 | ## Mejores Prácticas 229 | 230 | ### 1. **Nombres Únicos** 231 | Siempre usa generadores de valores únicos: 232 | ```gherkin 233 | Given I generate a unique alias as "testUser" 234 | ``` 235 | 236 | ### 2. **Validación Completa** 237 | Valida tanto el código de respuesta como el contenido: 238 | ```gherkin 239 | Then the response code should be 201 240 | And the JSON response should contain key "id" 241 | And the JSON response should contain "username": "${testUser}" 242 | ``` 243 | 244 | ### 3. **Manejo de Errores** 245 | Incluye tests para casos de error: 246 | ```gherkin 247 | Scenario: Attempt to create user with missing fields 248 | When I send a POST request to "/api/users" with body: 249 | """ 250 | { 251 | "firstName": "John" 252 | } 253 | """ 254 | Then the response code should be 400 255 | And the JSON response should contain key "error" 256 | ``` 257 | 258 | ### 4. **Limpieza de Recursos** 259 | Los recursos se limpian automáticamente, pero puedes limpiar manualmente: 260 | ```gherkin 261 | When I send a DELETE request to "/api/users/${userID}" 262 | Then the response code should be 200 263 | ``` 264 | 265 | ## Troubleshooting 266 | 267 | ### Problemas Comunes 268 | 269 | 1. **Error de conexión a base de datos** 270 | - Verifica que Docker Compose esté corriendo 271 | - Revisa las variables de entorno de conexión 272 | 273 | 2. **Tests fallando por recursos existentes** 274 | - Ejecuta con la opción `-c` para limpiar antes 275 | - Verifica que no haya tests corriendo en paralelo 276 | 277 | 3. **Errores de autenticación** 278 | - Verifica que las credenciales de test sean correctas 279 | - Revisa que el servidor esté corriendo 280 | 281 | 4. **Timeouts en tests** 282 | - Aumenta el timeout en la configuración 283 | - Verifica la conectividad de red 284 | 285 | ### Logs de Debug 286 | ```bash 287 | # Habilitar logs detallados 288 | export GODOG_DEBUG=true 289 | go test -v -tags=integration ./Test/integration/... 290 | ``` 291 | 292 | ## Contribución 293 | 294 | ### Agregar Nuevos Tests 295 | 296 | 1. **Crear archivo de feature**: 297 | ```bash 298 | touch Test/integration/features/nueva-funcionalidad.feature 299 | ``` 300 | 301 | 2. **Implementar pasos** (si es necesario): 302 | - Agregar funciones en `steps.go` 303 | - Registrar en `InitializeScenario` 304 | 305 | 3. **Ejecutar tests**: 306 | ```bash 307 | ./scripts/run-all-integration-tests.bash -f nueva-funcionalidad.feature 308 | ``` 309 | 310 | ### Convenciones de Nomenclatura 311 | 312 | - **Archivos de feature**: `kebab-case.feature` 313 | - **Escenarios**: `TC01 - Descripción del test` 314 | - **Variables**: `camelCase` o `snake_case` 315 | - **Tags**: `@smoke`, `@regression`, `@critical` 316 | 317 | ## Integración Continua 318 | 319 | ### GitHub Actions 320 | ```yaml 321 | - name: Run Integration Tests 322 | run: | 323 | docker-compose up -d 324 | ./scripts/run-all-integration-tests.bash -d -v 325 | ``` 326 | 327 | ### Jenkins Pipeline 328 | ```groovy 329 | stage('Integration Tests') { 330 | steps { 331 | sh './scripts/run-all-integration-tests.bash -d -v' 332 | } 333 | } 334 | ``` 335 | 336 | ## Recursos Adicionales 337 | 338 | - [Documentación de Godog](https://github.com/cucumber/godog) 339 | - [Sintaxis Gherkin](https://cucumber.io/docs/gherkin/) 340 | - [Testing en Go](https://golang.org/pkg/testing/) -------------------------------------------------------------------------------- /Test/integration/features/auth.feature: -------------------------------------------------------------------------------- 1 | Feature: User Login and Token Refresh 2 | As a registered user 3 | I want to authenticate and refresh my tokens 4 | So that I can access protected endpoints 5 | 6 | Scenario: POST /login with valid credentials returns tokens and user info 7 | When I send a POST request to "/v1/auth/login" with body: 8 | """ 9 | { 10 | "email": "${START_USER_EMAIL}", 11 | "password": "${START_USER_PW}" 12 | } 13 | """ 14 | Then the response code should be 200 15 | And the JSON response should contain key "security" 16 | And the JSON response should contain "data.email" with value "${START_USER_EMAIL}" 17 | And I save the JSON response key "security.jwtAccessToken" as "accessToken" 18 | And I save the JSON response key "security.jwtRefreshToken" as "refreshToken" 19 | 20 | Scenario: POST /login with invalid credentials returns 401 21 | When I send a POST request to "/v1/auth/login" with body: 22 | """ 23 | { 24 | "email": "${START_USER_EMAIL}", 25 | "password": "wrongpassword" 26 | } 27 | """ 28 | Then the response code should be 401 29 | And the JSON response should contain error "error": "email or password does not match" 30 | 31 | Scenario: POST /access-token/refresh with valid refresh token returns new access token 32 | When I send a POST request to "/v1/auth/access-token" with body: 33 | """ 34 | { 35 | "refreshToken": "${refreshToken}" 36 | } 37 | """ 38 | Then the response code should be 200 39 | And the JSON response should contain key "security" 40 | And the JSON response should contain key "data" 41 | And I save the JSON response key "security.jwtAccessToken" as "accessToken" 42 | 43 | Scenario: POST /access-token/refresh with invalid refresh token returns 401 44 | When I send a POST request to "/v1/auth/access-token" with body: 45 | """ 46 | { 47 | "refreshToken": "someInvalidToken" 48 | } 49 | """ 50 | Then the response code should be 401 51 | And the JSON response should contain error "error": "token contains an invalid number of segments" 52 | 53 | Scenario: Access protected endpoint without token 54 | Given I clear the authentication token 55 | When I send a GET request to "/v1/medicine/1" 56 | Then the response code should be 401 57 | And the JSON response should contain error "error": "Token not provided" 58 | 59 | # Re-authenticate so subsequent scenarios have a valid token 60 | Scenario: Re-authenticate after clearing the token 61 | When I send a POST request to "/v1/auth/login" with body: 62 | """ 63 | { 64 | "email": "${START_USER_EMAIL}", 65 | "password": "${START_USER_PW}" 66 | } 67 | """ 68 | Then the response code should be 200 69 | And the JSON response should contain key "security" 70 | And the JSON response should contain "data.email" with value "${START_USER_EMAIL}" 71 | And I save the JSON response key "security.jwtAccessToken" as "accessToken" 72 | And I save the JSON response key "security.jwtRefreshToken" as "refreshToken" 73 | -------------------------------------------------------------------------------- /Test/integration/features/medicine.feature: -------------------------------------------------------------------------------- 1 | Feature: Medicine Management 2 | As an API consumer 3 | I want to manage medicines 4 | So that I can perform CRUD operations 5 | 6 | Background: 7 | # Authentication handled globally 8 | 9 | Scenario: Create a new medicine successfully 10 | Given I generate a unique alias as "medName" 11 | When I send a POST request to "/v1/medicine" with body: 12 | """ 13 | { 14 | "name": "${medName}", 15 | "description": "Test medicine", 16 | "eanCode": "${medName}-EAN", 17 | "laboratory": "TestLab" 18 | } 19 | """ 20 | Then the response code should be 200 21 | And the JSON response should contain key "id" 22 | And I save the JSON response key "id" as "medicineID" 23 | 24 | Scenario: Retrieve created medicine 25 | When I send a GET request to "/v1/medicine/${medicineID}" 26 | Then the response code should be 200 27 | And the JSON response should contain "id" with numeric value ${medicineID} 28 | 29 | Scenario: Update medicine description 30 | When I send a PUT request to "/v1/medicine/${medicineID}" with body: 31 | """ 32 | { 33 | "description": "Updated description" 34 | } 35 | """ 36 | Then the response code should be 200 37 | And the JSON response should contain "description": "Updated description" 38 | 39 | Scenario: Delete medicine 40 | When I send a DELETE request to "/v1/medicine/${medicineID}" 41 | Then the response code should be 200 42 | And the JSON response should contain "message": "resource deleted successfully" 43 | 44 | Scenario: Search medicines paginated 45 | When I send a GET request to "/v1/medicine/search?page=1&pageSize=10" 46 | Then the response code should be 200 47 | And the JSON response should contain key "data" 48 | -------------------------------------------------------------------------------- /Test/integration/features/users.feature: -------------------------------------------------------------------------------- 1 | Feature: User Management 2 | As an API consumer 3 | I want to manage users 4 | So that I can perform CRUD operations 5 | 6 | Background: 7 | # Authentication handled globally 8 | 9 | Scenario: Create a new user successfully 10 | Given I generate a unique alias as "userName" 11 | When I send a POST request to "/v1/user" with body: 12 | """ 13 | { 14 | "user": "${userName}", 15 | "email": "${userName}@test.com", 16 | "firstName": "Test", 17 | "lastName": "User", 18 | "password": "pass123", 19 | "role": "user" 20 | } 21 | """ 22 | Then the response code should be 200 23 | And the JSON response should contain key "id" 24 | And I save the JSON response key "id" as "userID" 25 | 26 | Scenario: Retrieve created user 27 | When I send a GET request to "/v1/user/${userID}" 28 | Then the response code should be 200 29 | And the JSON response should contain "id" with numeric value ${userID} 30 | 31 | Scenario: Update user first name 32 | When I send a PUT request to "/v1/user/${userID}" with body: 33 | """ 34 | { 35 | "firstName": "Updated" 36 | } 37 | """ 38 | Then the response code should be 200 39 | And the JSON response should contain "firstName": "Updated" 40 | 41 | Scenario: Delete user 42 | When I send a DELETE request to "/v1/user/${userID}" 43 | Then the response code should be 200 44 | And the JSON response should contain "message": "resource deleted successfully" 45 | 46 | Scenario: Search users paginated 47 | When I send a GET request to "/v1/user/search?page=1&pageSize=5" 48 | Then the response code should be 200 49 | And the JSON response should contain key "data" 50 | -------------------------------------------------------------------------------- /Test/integration/main_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | // +build integration 3 | 4 | package integration 5 | 6 | import ( 7 | "os" 8 | "testing" 9 | 10 | "github.com/cucumber/godog" 11 | ) 12 | 13 | func TestIntegration(t *testing.T) { 14 | // Get feature file from environment variable if specified 15 | featureFile := os.Getenv("INTEGRATION_FEATURE_FILE") 16 | // Get specific scenario tags from environment variable if specified 17 | scenarioTags := os.Getenv("INTEGRATION_SCENARIO_TAGS") 18 | 19 | var paths []string 20 | if featureFile != "" { 21 | // Run only the specified feature file 22 | paths = []string{"features/" + featureFile} 23 | } else { 24 | // Run all feature files 25 | paths = []string{"features"} 26 | } 27 | 28 | options := &godog.Options{ 29 | Format: "pretty", 30 | Concurrency: 1, 31 | Paths: paths, 32 | } 33 | 34 | // Add tags filter if specific scenario tags are provided 35 | if scenarioTags != "" { 36 | options.Tags = scenarioTags 37 | } 38 | 39 | suite := godog.TestSuite{ 40 | Name: "integration", 41 | ScenarioInitializer: InitializeScenario, 42 | TestSuiteInitializer: InitializeTestSuite, 43 | Options: options, 44 | } 45 | 46 | if exitCode := suite.Run(); exitCode != 0 { 47 | os.Exit(exitCode) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | postgres: 5 | image: postgres:17.4 6 | restart: always 7 | env_file: 8 | - .env 9 | environment: 10 | - POSTGRES_DB=${POSTGRES_DB} 11 | - POSTGRES_USER=${POSTGRES_USER} 12 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 13 | healthcheck: 14 | test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] 15 | interval: 10s 16 | timeout: 5s 17 | start_period: 30s 18 | retries: 5 19 | ports: 20 | - "5432:5432" 21 | volumes: 22 | - pgdata:/var/lib/postgresql/data 23 | networks: 24 | - go-network 25 | 26 | go-microservice: 27 | build: 28 | context: . 29 | image: go-microservice 30 | restart: on-failure 31 | env_file: 32 | - .env 33 | ports: 34 | - "8080:8080" 35 | depends_on: 36 | postgres: 37 | condition: service_healthy 38 | networks: 39 | - go-network 40 | environment: 41 | # Server Configuration 42 | - SERVER_PORT=${SERVER_PORT:-8080} 43 | 44 | # Database Configuration 45 | - DB_HOST=${DB_HOST:-postgres} 46 | - DB_PORT=${DB_PORT:-5432} 47 | - DB_USER=${DB_USER} 48 | - DB_PASSWORD=${DB_PASSWORD} 49 | - DB_NAME=${DB_NAME} 50 | - DB_SSLMODE=${DB_SSLMODE:-disable} 51 | 52 | # Database Connection Pool Configuration 53 | - DB_MAX_IDLE_CONNS=${DB_MAX_IDLE_CONNS:-10} 54 | - DB_MAX_OPEN_CONNS=${DB_MAX_OPEN_CONNS:-50} 55 | - DB_CONN_MAX_LIFETIME=${DB_CONN_MAX_LIFETIME:-300} 56 | 57 | # JWT Configuration 58 | - JWT_ACCESS_SECRET_KEY=${JWT_ACCESS_SECRET_KEY} 59 | - JWT_ACCESS_TIME_MINUTE=${JWT_ACCESS_TIME_MINUTE:-15} 60 | - JWT_REFRESH_SECRET_KEY=${JWT_REFRESH_SECRET_KEY} 61 | - JWT_REFRESH_TIME_HOUR=${JWT_REFRESH_TIME_HOUR:-168} 62 | - JWT_ISSUER=${JWT_ISSUER} 63 | 64 | # Initial User Configuration 65 | - START_USER_EMAIL=${START_USER_EMAIL:-gbrayhan@gmail.com} 66 | - START_USER_PW=${START_USER_PW:-qweqwe} 67 | 68 | # Optional External Services 69 | - IMGUR_CLIENT_ID=${IMGUR_CLIENT_ID:-} 70 | - WKHTMLTOPDF_BIN=${WKHTMLTOPDF_BIN:-/usr/local/bin/wkhtmltopdf} 71 | 72 | 73 | volumes: 74 | pgdata: 75 | 76 | networks: 77 | go-network: 78 | driver: bridge 79 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation Index 2 | 3 | Welcome to the comprehensive documentation for the **Microservices Go** application. This documentation provides detailed information about the architecture, API, deployment, and development guidelines. 4 | 5 | ## 📚 Documentation Structure 6 | 7 | ### 🏗️ Architecture & Design 8 | 9 | - **[Clean Architecture Guide](README_CLEAN_ARCHITECTURE.md)** - Detailed implementation of Clean Architecture principles with diagrams and examples 10 | - **[API Documentation](API_DOCUMENTATION.md)** - Complete API reference with endpoints, examples, and authentication flows 11 | 12 | ### 🔍 Features & Endpoints 13 | 14 | - **[Search Endpoints](SEARCH_ENDPOINTS.md)** - Advanced search and pagination capabilities for Users and Medicines 15 | 16 | ### 🚀 Deployment & Operations 17 | 18 | - **[Deployment Guide](DEPLOYMENT_GUIDE.md)** - Comprehensive deployment strategies for Docker, Kubernetes, and CI/CD 19 | 20 | ## 🎯 Quick Start 21 | 22 | ### 1. **Understanding the Architecture** 23 | Start with the [Clean Architecture Guide](README_CLEAN_ARCHITECTURE.md) to understand the project structure and design principles. 24 | 25 | ### 2. **API Reference** 26 | Review the [API Documentation](API_DOCUMENTATION.md) to understand all available endpoints and their usage. 27 | 28 | ### 3. **Search Features** 29 | Explore the [Search Endpoints](SEARCH_ENDPOINTS.md) to learn about advanced filtering and pagination capabilities. 30 | 31 | ### 4. **Deployment** 32 | Follow the [Deployment Guide](DEPLOYMENT_GUIDE.md) to deploy the application in your preferred environment. 33 | 34 | ## 📊 Documentation Overview 35 | 36 | ### Architecture Diagrams 37 | 38 | The documentation includes comprehensive diagrams: 39 | 40 | - **Clean Architecture Layers** - Shows the dependency flow and layer structure 41 | - **Authentication Flow** - JWT token lifecycle and state management 42 | - **Data Flow** - Request/response patterns and error handling 43 | - **Deployment Architecture** - Docker and Kubernetes deployment strategies 44 | 45 | ### Code Examples 46 | 47 | Each documentation section includes: 48 | 49 | - **Go Code Examples** - Implementation patterns and best practices 50 | - **API Examples** - Request/response formats with curl commands 51 | - **Configuration Examples** - Environment variables and deployment configs 52 | - **Testing Examples** - Unit and integration test patterns 53 | 54 | ## 🔧 Development Workflow 55 | 56 | ### 1. **Local Development** 57 | ```bash 58 | # Clone and setup 59 | git clone https://github.com/gbrayhan/microservices-go 60 | cd microservices-go 61 | cp .env.example .env 62 | 63 | # Start services 64 | docker-compose up --build -d 65 | 66 | # Run tests 67 | ./coverage.sh 68 | ./scripts/run-integration-test.bash 69 | ``` 70 | 71 | ### 2. **API Testing** 72 | ```bash 73 | # Health check 74 | curl http://localhost:8080/v1/health 75 | 76 | # Test authentication 77 | curl -X POST http://localhost:8080/v1/auth/login \ 78 | -H "Content-Type: application/json" \ 79 | -d '{"email": "user@example.com", "password": "password123"}' 80 | ``` 81 | 82 | ### 3. **Code Quality** 83 | ```bash 84 | # Linting 85 | golangci-lint run ./... 86 | 87 | # Security scan 88 | trivy fs . 89 | gosec ./... 90 | ``` 91 | 92 | ## 📈 Key Features Documented 93 | 94 | ### ✅ Clean Architecture Implementation 95 | - **Domain Layer** - Business entities and rules 96 | - **Application Layer** - Use cases and business logic 97 | - **Infrastructure Layer** - External implementations 98 | - **Dependency Injection** - Centralized dependency management 99 | 100 | ### ✅ Authentication & Security 101 | - **JWT Authentication** - Access and refresh tokens 102 | - **Password Security** - bcrypt hashing with salt 103 | - **Input Validation** - Request sanitization and validation 104 | - **Error Handling** - Centralized error management 105 | 106 | ### ✅ Advanced Search & Pagination 107 | - **Multi-field Search** - LIKE and exact match filters 108 | - **Date Range Filtering** - Time-based queries 109 | - **Sorting** - Multi-field sorting with direction control 110 | - **Pagination** - Efficient result pagination 111 | 112 | ### ✅ Testing Strategy 113 | - **Unit Tests** - Use case and controller testing 114 | - **Integration Tests** - API endpoint testing 115 | - **Acceptance Tests** - Cucumber-based BDD tests 116 | - **Coverage Analysis** - Comprehensive test coverage reporting 117 | 118 | ### ✅ Deployment Options 119 | - **Docker** - Containerized deployment 120 | - **Kubernetes** - Orchestrated deployment 121 | - **CI/CD** - Automated testing and deployment 122 | - **Monitoring** - Health checks and metrics 123 | 124 | ## 🎯 Best Practices 125 | 126 | ### Code Quality 127 | - **Clean Architecture** - Follow dependency inversion principles 128 | - **Test Coverage** - Maintain ≥80% test coverage 129 | - **Error Handling** - Use centralized error management 130 | - **Logging** - Structured logging with correlation IDs 131 | 132 | ### Security 133 | - **Authentication** - JWT with short-lived access tokens 134 | - **Authorization** - Role-based access control 135 | - **Input Validation** - Validate and sanitize all inputs 136 | - **Security Headers** - Implement security headers 137 | 138 | ### Performance 139 | - **Database Optimization** - Proper indexing and query optimization 140 | - **Connection Pooling** - Efficient database connection management 141 | - **Caching** - Implement caching strategies 142 | - **Monitoring** - Performance metrics and health checks 143 | 144 | ## 🔄 Documentation Maintenance 145 | 146 | ### Contributing to Documentation 147 | 1. **Update Diagrams** - Use Mermaid for architecture diagrams 148 | 2. **Code Examples** - Keep examples up-to-date with code changes 149 | 3. **API Documentation** - Update when endpoints change 150 | 4. **Deployment Guides** - Update for new deployment options 151 | 152 | ### Documentation Standards 153 | - **English Only** - All documentation in English 154 | - **Clear Structure** - Use consistent headings and formatting 155 | - **Code Examples** - Include working code examples 156 | - **Diagrams** - Use Mermaid for visual documentation 157 | 158 | ## 📞 Support & Resources 159 | 160 | ### Getting Help 161 | - **GitHub Issues** - Report bugs and request features 162 | - **GitHub Discussions** - Ask questions and share ideas 163 | - **Documentation** - This comprehensive documentation 164 | - **API Documentation** - Complete REST API reference 165 | 166 | ### Additional Resources 167 | - **Go Documentation** - [golang.org](https://golang.org/doc/) 168 | - **Gin Framework** - [gin-gonic.com](https://gin-gonic.com/) 169 | - **GORM** - [gorm.io](https://gorm.io/) 170 | - **Clean Architecture** - [blog.cleancoder.com](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 171 | 172 | ## 📝 Documentation Changelog 173 | 174 | ### v2.0.0 (Latest) 175 | - ✅ **Complete English Translation** - All documentation translated to English 176 | - ✅ **Comprehensive Diagrams** - Added Mermaid diagrams for all flows 177 | - ✅ **API Documentation** - Complete API reference with examples 178 | - ✅ **Deployment Guide** - Docker, Kubernetes, and CI/CD strategies 179 | - ✅ **Search Documentation** - Advanced search and pagination features 180 | - ✅ **Testing Documentation** - Unit, integration, and acceptance testing 181 | 182 | ### v1.0.0 183 | - ✅ **Basic Architecture** - Initial Clean Architecture documentation 184 | - ✅ **API Endpoints** - Basic endpoint documentation 185 | - ✅ **Deployment** - Simple Docker deployment guide 186 | 187 | --- 188 | 189 | **Last Updated**: 2024 190 | **Documentation Version**: 2.0.0 191 | **Status**: Complete and Up-to-Date -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gbrayhan/microservices-go 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/DATA-DOG/go-sqlmock v1.5.0 7 | github.com/cucumber/godog v0.15.0 8 | github.com/gin-contrib/cors v1.7.5 9 | github.com/gin-gonic/gin v1.10.0 10 | github.com/go-playground/validator/v10 v10.26.0 11 | github.com/golang-jwt/jwt/v4 v4.5.2 12 | github.com/google/uuid v1.6.0 13 | github.com/stretchr/testify v1.10.0 14 | go.uber.org/zap v1.27.0 15 | golang.org/x/crypto v0.37.0 16 | gorm.io/driver/postgres v1.5.11 17 | gorm.io/gorm v1.30.0 18 | ) 19 | 20 | require ( 21 | github.com/bytedance/sonic v1.13.2 // indirect 22 | github.com/bytedance/sonic/loader v0.2.4 // indirect 23 | github.com/cloudwego/base64x v0.1.5 // indirect 24 | github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect 25 | github.com/cucumber/messages/go/v21 v21.0.1 // indirect 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 27 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 28 | github.com/gin-contrib/sse v1.1.0 // indirect 29 | github.com/go-playground/locales v0.14.1 // indirect 30 | github.com/go-playground/universal-translator v0.18.1 // indirect 31 | github.com/goccy/go-json v0.10.5 // indirect 32 | github.com/gofrs/uuid v4.3.1+incompatible // indirect 33 | github.com/google/go-cmp v0.6.0 // indirect 34 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 35 | github.com/hashicorp/go-memdb v1.3.4 // indirect 36 | github.com/hashicorp/golang-lru v0.5.4 // indirect 37 | github.com/jackc/pgpassfile v1.0.0 // indirect 38 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 39 | github.com/jackc/pgx/v5 v5.7.4 // indirect 40 | github.com/jackc/puddle/v2 v2.2.2 // indirect 41 | github.com/jinzhu/inflection v1.0.0 // indirect 42 | github.com/jinzhu/now v1.1.5 // indirect 43 | github.com/json-iterator/go v1.1.12 // indirect 44 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 45 | github.com/kr/pretty v0.3.1 // indirect 46 | github.com/leodido/go-urn v1.4.0 // indirect 47 | github.com/mattn/go-isatty v0.0.20 // indirect 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 49 | github.com/modern-go/reflect2 v1.0.2 // indirect 50 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 51 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 52 | github.com/rogpeppe/go-internal v1.11.0 // indirect 53 | github.com/spf13/pflag v1.0.5 // indirect 54 | github.com/stretchr/objx v0.5.2 // indirect 55 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 56 | github.com/ugorji/go/codec v1.2.12 // indirect 57 | go.uber.org/multierr v1.10.0 // indirect 58 | golang.org/x/arch v0.16.0 // indirect 59 | golang.org/x/net v0.39.0 // indirect 60 | golang.org/x/sync v0.13.0 // indirect 61 | golang.org/x/sys v0.32.0 // indirect 62 | golang.org/x/text v0.24.0 // indirect 63 | google.golang.org/protobuf v1.36.6 // indirect 64 | gopkg.in/yaml.v3 v3.0.1 // indirect 65 | ) 66 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | gofmt: 5 | glob: "**/*.go" 6 | run: go fmt ./... 7 | 8 | go-imports: 9 | glob: "**/*.go" 10 | run: goimports -w . 11 | 12 | go-mod-tidy: 13 | run: go mod tidy -v 14 | 15 | go-build: 16 | run: go build ./... 17 | 18 | golangci-lint: 19 | glob: "**/*.go" 20 | run: golangci-lint run --timeout=3m --out-format=colored-line-number 21 | 22 | staticcheck: 23 | glob: "**/*.go" 24 | run: staticcheck ./... 25 | 26 | go-vet: 27 | glob: "**/*.go" 28 | run: go vet ./... 29 | 30 | unit-tests: 31 | glob: "**/*_test.go" 32 | run: go test -count=1 -timeout=30s ./... 33 | 34 | trivy-scan: 35 | run: trivy fs . --scanners vuln,misconfig,secret --exit-code 1 --skip-dirs .git,vendor --quiet 36 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "time" 8 | 9 | "github.com/gbrayhan/microservices-go/src/infrastructure/di" 10 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 11 | "github.com/gbrayhan/microservices-go/src/infrastructure/rest/middlewares" 12 | "github.com/gbrayhan/microservices-go/src/infrastructure/rest/routes" 13 | "github.com/gin-contrib/cors" 14 | "github.com/gin-gonic/gin" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // ServerConfig holds server-related configuration 19 | type ServerConfig struct { 20 | Port string 21 | } 22 | 23 | // loadServerConfig loads server configuration from environment variables 24 | func loadServerConfig() ServerConfig { 25 | return ServerConfig{ 26 | Port: getEnvOrDefault("SERVER_PORT", "8080"), 27 | } 28 | } 29 | 30 | func main() { 31 | // Initialize logger first based on environment 32 | env := getEnvOrDefault("GO_ENV", "development") 33 | var loggerInstance *logger.Logger 34 | var err error 35 | 36 | if env == "development" { 37 | loggerInstance, err = logger.NewDevelopmentLogger() 38 | } else { 39 | loggerInstance, err = logger.NewLogger() 40 | } 41 | 42 | if err != nil { 43 | panic(fmt.Errorf("error initializing logger: %w", err)) 44 | } 45 | defer func() { 46 | if err := loggerInstance.Log.Sync(); err != nil { 47 | loggerInstance.Log.Error("Failed to sync logger", zap.Error(err)) 48 | } 49 | }() 50 | 51 | loggerInstance.Info("Starting microservices application") 52 | 53 | // Load server configuration 54 | serverConfig := loadServerConfig() 55 | 56 | // Initialize application context with dependencies and logger 57 | appContext, err := di.SetupDependencies(loggerInstance) 58 | if err != nil { 59 | loggerInstance.Panic("Error initializing application context", zap.Error(err)) 60 | } 61 | 62 | // Setup router 63 | router := setupRouter(appContext, loggerInstance) 64 | 65 | // Setup server 66 | server := setupServer(router, serverConfig.Port) 67 | 68 | // Start server 69 | loggerInstance.Info("Server starting", zap.String("port", serverConfig.Port)) 70 | if err := server.ListenAndServe(); err != nil { 71 | loggerInstance.Panic("Server failed to start", zap.Error(err)) 72 | } 73 | } 74 | 75 | func setupRouter(appContext *di.ApplicationContext, logger *logger.Logger) *gin.Engine { 76 | // Configurar Gin para usar el logger de Zap basado en el entorno 77 | env := getEnvOrDefault("GO_ENV", "development") 78 | if env == "development" { 79 | logger.SetupGinWithZapLoggerInDevelopment() 80 | } else { 81 | logger.SetupGinWithZapLogger() 82 | } 83 | 84 | // Crear el router después de configurar el logger 85 | router := gin.New() 86 | 87 | // Agregar middlewares de recuperación y logger personalizados 88 | router.Use(gin.Recovery()) 89 | router.Use(cors.Default()) 90 | 91 | // Add middlewares 92 | router.Use(middlewares.ErrorHandler()) 93 | router.Use(middlewares.GinBodyLogMiddleware) 94 | router.Use(middlewares.CommonHeaders) 95 | 96 | // Add logger middleware 97 | router.Use(logger.GinZapLogger()) 98 | 99 | // Setup routes 100 | routes.ApplicationRouter(router, appContext) 101 | return router 102 | } 103 | 104 | func setupServer(router *gin.Engine, port string) *http.Server { 105 | return &http.Server{ 106 | Addr: ":" + port, 107 | Handler: router, 108 | ReadTimeout: 18000 * time.Second, 109 | WriteTimeout: 18000 * time.Second, 110 | MaxHeaderBytes: 1 << 20, 111 | } 112 | } 113 | 114 | // Helper function 115 | func getEnvOrDefault(key, defaultValue string) string { 116 | if value := os.Getenv(key); value != "" { 117 | return value 118 | } 119 | return defaultValue 120 | } 121 | -------------------------------------------------------------------------------- /scripts/run-integration-test.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | trap 'error_handler $LINENO' ERR 5 | 6 | # Parse command line arguments 7 | VERBOSE=false 8 | FEATURE_FILE="" 9 | SCENARIO_TAGS="" 10 | while [[ $# -gt 0 ]]; do 11 | case $1 in 12 | -v|--verbose) 13 | VERBOSE=true 14 | shift 15 | ;; 16 | -f|--feature) 17 | FEATURE_FILE="$2" 18 | shift 2 19 | ;; 20 | -t|--tags) 21 | SCENARIO_TAGS="$2" 22 | shift 2 23 | ;; 24 | *) 25 | echo "Usage: $0 [-v|--verbose] [-f|--feature ] [-t|--tags ]" 26 | echo " -v, --verbose Enable verbose output for tests" 27 | echo " -f, --feature Run only the specified feature file (e.g., auth.feature)" 28 | echo " -t, --tags Run only scenarios with specific tags (e.g., @smoke @critical)" 29 | echo 30 | echo "Examples:" 31 | echo " $0 # Run all integration tests" 32 | echo " $0 -v # Run all tests with verbose output" 33 | echo " $0 -f auth.feature # Run only auth.feature tests" 34 | echo " $0 -t @smoke # Run only scenarios tagged with @smoke" 35 | echo " $0 -f auth.feature -t @critical # Run only critical scenarios in auth.feature" 36 | echo " $0 -v -f order.feature -t @smoke # Run smoke tests in order.feature with verbose output" 37 | echo 38 | echo "Tag Examples:" 39 | echo " @smoke - Quick tests for basic functionality" 40 | echo " @critical - Critical path tests" 41 | echo " @slow - Tests that take longer to run" 42 | echo " @auth - Authentication related tests" 43 | echo " @api - API endpoint tests" 44 | exit 1 45 | ;; 46 | esac 47 | done 48 | 49 | error_handler() { 50 | local exit_code=$? 51 | local line_no=$1 52 | echo "❌ Error on line $line_no (exit code $exit_code)." 53 | echo " ⮡ Check the output of the previous steps to identify the cause." 54 | exit "$exit_code" 55 | } 56 | 57 | # Function to validate required environment variables 58 | validate_required_env_vars() { 59 | local missing_vars=() 60 | 61 | # Database variables 62 | [[ -z "${DB_HOST:-}" ]] && missing_vars+=("DB_HOST") 63 | [[ -z "${DB_PORT:-}" ]] && missing_vars+=("DB_PORT") 64 | [[ -z "${DB_USER:-}" ]] && missing_vars+=("DB_USER") 65 | [[ -z "${DB_PASSWORD:-}" ]] && missing_vars+=("DB_PASSWORD") 66 | [[ -z "${DB_NAME:-}" ]] && missing_vars+=("DB_NAME") 67 | [[ -z "${DB_SSLMODE:-}" ]] && missing_vars+=("DB_SSLMODE") 68 | 69 | # JWT variables 70 | [[ -z "${JWT_ACCESS_SECRET_KEY:-}" ]] && missing_vars+=("JWT_ACCESS_SECRET_KEY") 71 | [[ -z "${JWT_REFRESH_SECRET_KEY:-}" ]] && missing_vars+=("JWT_REFRESH_SECRET_KEY") 72 | [[ -z "${JWT_ISSUER:-}" ]] && missing_vars+=("JWT_ISSUER") 73 | 74 | # External services 75 | [[ -z "${IMGUR_CLIENT_ID:-}" ]] && missing_vars+=("IMGUR_CLIENT_ID") 76 | 77 | 78 | # Initial user (for migrations) 79 | [[ -z "${START_USER_EMAIL:-}" ]] && missing_vars+=("START_USER_EMAIL") 80 | [[ -z "${START_USER_PW:-}" ]] && missing_vars+=("START_USER_PW") 81 | 82 | # Optional variables with defaults 83 | [[ -z "${APP_PORT:-}" ]] && export APP_PORT=8080 84 | [[ -z "${JWT_ACCESS_TOKEN_TTL:-}" ]] && export JWT_ACCESS_TOKEN_TTL=15 85 | [[ -z "${JWT_REFRESH_TOKEN_TTL:-}" ]] && export JWT_REFRESH_TOKEN_TTL=10080 86 | 87 | if [[ ${#missing_vars[@]} -gt 0 ]]; then 88 | echo "❌ Error: The following required environment variables are not set:" 89 | printf " - %s\n" "${missing_vars[@]}" 90 | echo 91 | echo "Please set these variables before running the integration tests." 92 | exit 1 93 | fi 94 | } 95 | 96 | BUILD_NAME="dev-aceso" 97 | 98 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 99 | PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" 100 | 101 | echo "🔍 Validating required environment variables..." 102 | validate_required_env_vars 103 | echo "✅ All required environment variables are set" 104 | 105 | echo "🛠 Compiling project from '$PROJECT_ROOT'..." 106 | cd "$PROJECT_ROOT" 107 | go build -o "$BUILD_NAME" . 108 | 109 | echo "🔍 Ensuring port $APP_PORT is free..." 110 | PIDS=$(lsof -ti tcp:"$APP_PORT" || true) 111 | if [[ -n "$PIDS" ]]; then 112 | echo "⚠️ Killing stale process(es) on port $APP_PORT: $PIDS" 113 | kill -9 $PIDS 114 | while lsof -ti tcp:"$APP_PORT" >/dev/null; do sleep 0.1; done 115 | echo "✅ Port $APP_PORT is now free." 116 | else 117 | echo "✅ Port $APP_PORT was already free." 118 | fi 119 | 120 | echo "🔧 Environment variables are already set and validated" 121 | 122 | echo "▶️ Starting the application (logs suppressed)…" 123 | "$PROJECT_ROOT/$BUILD_NAME" > /dev/null 2>&1 & 124 | APP_PID=$! 125 | 126 | until lsof -ti tcp:"$APP_PORT" >/dev/null; do sleep 0.1; done 127 | echo "✅ App listening on port $APP_PORT (PID $APP_PID)" 128 | 129 | echo 130 | echo "🧪 Running integration tests…" 131 | trap '' ERR 132 | set +e 133 | 134 | # Set integration test environment variable 135 | export INTEGRATION_TEST=true 136 | 137 | # Build the test command with optional verbose flag 138 | TEST_CMD="go test -count=1 ./Test/integration -tags=integration" 139 | if [[ "$VERBOSE" == "true" ]]; then 140 | TEST_CMD="$TEST_CMD -v" 141 | fi 142 | 143 | if [[ -n "$FEATURE_FILE" ]]; then 144 | # Validate that the feature file exists 145 | FEATURE_PATH="$PROJECT_ROOT/Test/integration/features/$FEATURE_FILE" 146 | if [[ ! -f "$FEATURE_PATH" ]]; then 147 | echo "❌ Error: Feature file '$FEATURE_FILE' not found at '$FEATURE_PATH'" 148 | echo "Available feature files:" 149 | ls -1 "$PROJECT_ROOT/Test/integration/features/"*.feature | sed 's|.*/||' | sed 's/^/ - /' 150 | exit 1 151 | fi 152 | echo "🎯 Running only feature file: $FEATURE_FILE" 153 | export INTEGRATION_FEATURE_FILE="$FEATURE_FILE" 154 | else 155 | echo "🧪 Running all integration tests..." 156 | unset INTEGRATION_FEATURE_FILE 157 | fi 158 | 159 | if [[ -n "$SCENARIO_TAGS" ]]; then 160 | echo "🎯 Running scenarios with tags: $SCENARIO_TAGS" 161 | export INTEGRATION_SCENARIO_TAGS="$SCENARIO_TAGS" 162 | else 163 | unset INTEGRATION_SCENARIO_TAGS 164 | fi 165 | 166 | $TEST_CMD 167 | TEST_EXIT=$? 168 | set -e 169 | trap 'error_handler $LINENO' ERR 170 | 171 | if [ $TEST_EXIT -eq 0 ]; then 172 | echo "🎉 Integration tests passed!" 173 | else 174 | echo "⚠️ Integration tests finished with exit code $TEST_EXIT." 175 | fi 176 | 177 | echo "🛑 Stopping the application (PID $APP_PID)…" 178 | kill "$APP_PID" 2>/dev/null || true 179 | echo "✅ Application stopped." 180 | 181 | echo "💡 All done." 182 | exit $TEST_EXIT -------------------------------------------------------------------------------- /src/application/usecases/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | domainErrors "github.com/gbrayhan/microservices-go/src/domain/errors" 8 | domainUser "github.com/gbrayhan/microservices-go/src/domain/user" 9 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 10 | "github.com/gbrayhan/microservices-go/src/infrastructure/repository/psql/user" 11 | "github.com/gbrayhan/microservices-go/src/infrastructure/security" 12 | "go.uber.org/zap" 13 | "golang.org/x/crypto/bcrypt" 14 | ) 15 | 16 | type IAuthUseCase interface { 17 | Login(email, password string) (*domainUser.User, *AuthTokens, error) 18 | AccessTokenByRefreshToken(refreshToken string) (*domainUser.User, *AuthTokens, error) 19 | } 20 | 21 | type AuthUseCase struct { 22 | UserRepository user.UserRepositoryInterface 23 | JWTService security.IJWTService 24 | Logger *logger.Logger 25 | } 26 | 27 | func NewAuthUseCase(userRepository user.UserRepositoryInterface, jwtService security.IJWTService, loggerInstance *logger.Logger) IAuthUseCase { 28 | return &AuthUseCase{ 29 | UserRepository: userRepository, 30 | JWTService: jwtService, 31 | Logger: loggerInstance, 32 | } 33 | } 34 | 35 | type AuthTokens struct { 36 | AccessToken string 37 | RefreshToken string 38 | ExpirationAccessDateTime time.Time 39 | ExpirationRefreshDateTime time.Time 40 | } 41 | 42 | func (s *AuthUseCase) Login(email, password string) (*domainUser.User, *AuthTokens, error) { 43 | s.Logger.Info("User login attempt", zap.String("email", email)) 44 | user, err := s.UserRepository.GetByEmail(email) 45 | if err != nil { 46 | s.Logger.Error("Error getting user for login", zap.Error(err), zap.String("email", email)) 47 | return nil, nil, err 48 | } 49 | if user.ID == 0 { 50 | s.Logger.Warn("Login failed: user not found", zap.String("email", email)) 51 | return nil, nil, domainErrors.NewAppError(errors.New("email or password does not match"), domainErrors.NotAuthenticated) 52 | } 53 | 54 | isAuthenticated := checkPasswordHash(password, user.HashPassword) 55 | if !isAuthenticated { 56 | s.Logger.Warn("Login failed: invalid password", zap.String("email", email)) 57 | return nil, nil, domainErrors.NewAppError(errors.New("email or password does not match"), domainErrors.NotAuthenticated) 58 | } 59 | 60 | accessTokenClaims, err := s.JWTService.GenerateJWTToken(user.ID, "access") 61 | if err != nil { 62 | s.Logger.Error("Error generating access token", zap.Error(err), zap.Int("userID", user.ID)) 63 | return nil, nil, err 64 | } 65 | refreshTokenClaims, err := s.JWTService.GenerateJWTToken(user.ID, "refresh") 66 | if err != nil { 67 | s.Logger.Error("Error generating refresh token", zap.Error(err), zap.Int("userID", user.ID)) 68 | return nil, nil, err 69 | } 70 | 71 | authTokens := &AuthTokens{ 72 | AccessToken: accessTokenClaims.Token, 73 | RefreshToken: refreshTokenClaims.Token, 74 | ExpirationAccessDateTime: accessTokenClaims.ExpirationTime, 75 | ExpirationRefreshDateTime: refreshTokenClaims.ExpirationTime, 76 | } 77 | 78 | s.Logger.Info("User login successful", zap.String("email", email), zap.Int("userID", user.ID)) 79 | return user, authTokens, nil 80 | } 81 | 82 | func (s *AuthUseCase) AccessTokenByRefreshToken(refreshToken string) (*domainUser.User, *AuthTokens, error) { 83 | s.Logger.Info("Refreshing access token") 84 | claimsMap, err := s.JWTService.GetClaimsAndVerifyToken(refreshToken, "refresh") 85 | if err != nil { 86 | s.Logger.Error("Error verifying refresh token", zap.Error(err)) 87 | return nil, nil, err 88 | } 89 | userID := int(claimsMap["id"].(float64)) 90 | user, err := s.UserRepository.GetByID(userID) 91 | if err != nil { 92 | s.Logger.Error("Error getting user for token refresh", zap.Error(err), zap.Int("userID", userID)) 93 | return nil, nil, err 94 | } 95 | 96 | accessTokenClaims, err := s.JWTService.GenerateJWTToken(user.ID, "access") 97 | if err != nil { 98 | s.Logger.Error("Error generating new access token", zap.Error(err), zap.Int("userID", user.ID)) 99 | return nil, nil, err 100 | } 101 | 102 | var expTime = int64(claimsMap["exp"].(float64)) 103 | 104 | authTokens := &AuthTokens{ 105 | AccessToken: accessTokenClaims.Token, 106 | ExpirationAccessDateTime: accessTokenClaims.ExpirationTime, 107 | RefreshToken: refreshToken, 108 | ExpirationRefreshDateTime: time.Unix(expTime, 0), 109 | } 110 | 111 | s.Logger.Info("Access token refreshed successfully", zap.Int("userID", user.ID)) 112 | return user, authTokens, nil 113 | } 114 | 115 | func checkPasswordHash(password, hash string) bool { 116 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 117 | return err == nil 118 | } 119 | -------------------------------------------------------------------------------- /src/application/usecases/medicine/medicine.go: -------------------------------------------------------------------------------- 1 | package medicine 2 | 3 | import ( 4 | "github.com/gbrayhan/microservices-go/src/domain" 5 | medicineDomain "github.com/gbrayhan/microservices-go/src/domain/medicine" 6 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 7 | "github.com/gbrayhan/microservices-go/src/infrastructure/repository/psql/medicine" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type IMedicineUseCase interface { 12 | GetByID(id int) (*medicineDomain.Medicine, error) 13 | Create(medicine *medicineDomain.Medicine) (*medicineDomain.Medicine, error) 14 | Delete(id int) error 15 | Update(id int, medicineMap map[string]any) (*medicineDomain.Medicine, error) 16 | GetAll() (*[]medicineDomain.Medicine, error) 17 | SearchPaginated(filters domain.DataFilters) (*medicineDomain.SearchResultMedicine, error) 18 | SearchByProperty(property string, searchText string) (*[]string, error) 19 | } 20 | 21 | type MedicineUseCase struct { 22 | medicineRepository medicine.MedicineRepositoryInterface 23 | Logger *logger.Logger 24 | } 25 | 26 | func NewMedicineUseCase(medicineRepository medicine.MedicineRepositoryInterface, loggerInstance *logger.Logger) IMedicineUseCase { 27 | return &MedicineUseCase{ 28 | medicineRepository: medicineRepository, 29 | Logger: loggerInstance, 30 | } 31 | } 32 | 33 | func (s *MedicineUseCase) GetByID(id int) (*medicineDomain.Medicine, error) { 34 | s.Logger.Info("Getting medicine by ID", zap.Int("id", id)) 35 | return s.medicineRepository.GetByID(id) 36 | } 37 | 38 | func (s *MedicineUseCase) Create(medicine *medicineDomain.Medicine) (*medicineDomain.Medicine, error) { 39 | s.Logger.Info("Creating new medicine", zap.String("name", medicine.Name)) 40 | return s.medicineRepository.Create(medicine) 41 | } 42 | 43 | func (s *MedicineUseCase) Delete(id int) error { 44 | s.Logger.Info("Deleting medicine", zap.Int("id", id)) 45 | return s.medicineRepository.Delete(id) 46 | } 47 | 48 | func (s *MedicineUseCase) Update(id int, medicineMap map[string]any) (*medicineDomain.Medicine, error) { 49 | s.Logger.Info("Updating medicine", zap.Int("id", id)) 50 | return s.medicineRepository.Update(id, medicineMap) 51 | } 52 | 53 | func (s *MedicineUseCase) GetAll() (*[]medicineDomain.Medicine, error) { 54 | s.Logger.Info("Getting all medicines") 55 | return s.medicineRepository.GetAll() 56 | } 57 | 58 | func (s *MedicineUseCase) SearchPaginated(filters domain.DataFilters) (*medicineDomain.SearchResultMedicine, error) { 59 | s.Logger.Info("Searching medicines with pagination", 60 | zap.Int("page", filters.Page), 61 | zap.Int("pageSize", filters.PageSize)) 62 | return s.medicineRepository.SearchPaginated(filters) 63 | } 64 | 65 | func (s *MedicineUseCase) SearchByProperty(property string, searchText string) (*[]string, error) { 66 | s.Logger.Info("Searching medicines by property", 67 | zap.String("property", property), 68 | zap.String("searchText", searchText)) 69 | return s.medicineRepository.SearchByProperty(property, searchText) 70 | } 71 | -------------------------------------------------------------------------------- /src/application/usecases/medicine/medicine_test.go: -------------------------------------------------------------------------------- 1 | package medicine 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/gbrayhan/microservices-go/src/domain" 9 | medicineDomain "github.com/gbrayhan/microservices-go/src/domain/medicine" 10 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 11 | ) 12 | 13 | type mockMedicineService struct { 14 | getByIDFn func(id int) (*medicineDomain.Medicine, error) 15 | createFn func(m *medicineDomain.Medicine) (*medicineDomain.Medicine, error) 16 | deleteFn func(id int) error 17 | updateFn func(id int, m map[string]any) (*medicineDomain.Medicine, error) 18 | getAllFn func() (*[]medicineDomain.Medicine, error) 19 | } 20 | 21 | func (m *mockMedicineService) GetByID(id int) (*medicineDomain.Medicine, error) { 22 | return m.getByIDFn(id) 23 | } 24 | 25 | func (m *mockMedicineService) Create(med *medicineDomain.Medicine) (*medicineDomain.Medicine, error) { 26 | return m.createFn(med) 27 | } 28 | 29 | func (m *mockMedicineService) Delete(id int) error { 30 | return m.deleteFn(id) 31 | } 32 | 33 | func (m *mockMedicineService) Update(id int, med map[string]any) (*medicineDomain.Medicine, error) { 34 | return m.updateFn(id, med) 35 | } 36 | 37 | func (m *mockMedicineService) GetAll() (*[]medicineDomain.Medicine, error) { 38 | return m.getAllFn() 39 | } 40 | 41 | func (m *mockMedicineService) SearchPaginated(filters domain.DataFilters) (*medicineDomain.SearchResultMedicine, error) { 42 | return nil, nil 43 | } 44 | 45 | func (m *mockMedicineService) SearchByProperty(property string, searchText string) (*[]string, error) { 46 | return nil, nil 47 | } 48 | 49 | func setupLogger(t *testing.T) *logger.Logger { 50 | loggerInstance, err := logger.NewLogger() 51 | if err != nil { 52 | t.Fatalf("Failed to create logger: %v", err) 53 | } 54 | return loggerInstance 55 | } 56 | 57 | func TestMedicineUseCase(t *testing.T) { 58 | mockRepo := &mockMedicineService{} 59 | loggerInstance := setupLogger(t) 60 | useCase := NewMedicineUseCase(mockRepo, loggerInstance) 61 | 62 | mockRepo.getByIDFn = func(id int) (*medicineDomain.Medicine, error) { 63 | if id == 123 { 64 | return &medicineDomain.Medicine{ID: 123}, nil 65 | } 66 | return nil, errors.New("not found") 67 | } 68 | _, err := useCase.GetByID(999) 69 | if err == nil { 70 | t.Error("expected error for not found, got nil") 71 | } 72 | med, err := useCase.GetByID(123) 73 | if err != nil { 74 | t.Errorf("unexpected error: %v", err) 75 | } 76 | if med.ID != 123 { 77 | t.Error("expected medicine ID=123") 78 | } 79 | 80 | mockRepo.createFn = func(m *medicineDomain.Medicine) (*medicineDomain.Medicine, error) { 81 | if m.Name == "" { 82 | return nil, errors.New("validation error") 83 | } 84 | m.ID = 999 85 | return m, nil 86 | } 87 | _, err = useCase.Create(&medicineDomain.Medicine{Name: ""}) 88 | if err == nil { 89 | t.Error("expected create error on empty name") 90 | } 91 | newMed, err := useCase.Create(&medicineDomain.Medicine{Name: "Aspirin"}) 92 | if err != nil { 93 | t.Errorf("unexpected error: %v", err) 94 | } 95 | if newMed.ID != 999 { 96 | t.Error("expected created medicine ID=999") 97 | } 98 | 99 | mockRepo.deleteFn = func(id int) error { 100 | if id == 1010 { 101 | return nil 102 | } 103 | return errors.New("cannot delete") 104 | } 105 | err = useCase.Delete(100) 106 | if err == nil { 107 | t.Error("expected error, got nil") 108 | } 109 | err = useCase.Delete(1010) 110 | if err != nil { 111 | t.Errorf("unexpected error: %v", err) 112 | } 113 | 114 | mockRepo.updateFn = func(id int, mm map[string]any) (*medicineDomain.Medicine, error) { 115 | if id != 1000 { 116 | return nil, errors.New("not found for update") 117 | } 118 | return &medicineDomain.Medicine{ID: 1000, Name: "UpdatedName"}, nil 119 | } 120 | _, err = useCase.Update(999, map[string]any{"name": "whatever"}) 121 | if err == nil { 122 | t.Error("expected error, got nil") 123 | } 124 | updated, err := useCase.Update(1000, map[string]any{"name": "NewName"}) 125 | if err != nil { 126 | t.Errorf("unexpected error: %v", err) 127 | } 128 | if updated.Name != "UpdatedName" { 129 | t.Error("expected updated name to be UpdatedName") 130 | } 131 | 132 | mockRepo.getAllFn = func() (*[]medicineDomain.Medicine, error) { 133 | return &[]medicineDomain.Medicine{ 134 | {ID: 1, Name: "M1"}, {ID: 2, Name: "M2"}, 135 | }, nil 136 | } 137 | meds, err := useCase.GetAll() 138 | if err != nil { 139 | t.Errorf("unexpected error: %v", err) 140 | } 141 | if meds == nil || len(*meds) != 2 { 142 | t.Error("expected 2 medicines from GetAll()") 143 | } 144 | } 145 | 146 | func TestNewMedicineUseCase(t *testing.T) { 147 | mockRepo := &mockMedicineService{} 148 | loggerInstance := setupLogger(t) 149 | uc := NewMedicineUseCase(mockRepo, loggerInstance) 150 | if reflect.TypeOf(uc).String() != "*medicine.MedicineUseCase" { 151 | t.Error("expected *medicine.MedicineUseCase type") 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/application/usecases/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/gbrayhan/microservices-go/src/domain" 5 | userDomain "github.com/gbrayhan/microservices-go/src/domain/user" 6 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 7 | "github.com/gbrayhan/microservices-go/src/infrastructure/repository/psql/user" 8 | "go.uber.org/zap" 9 | "golang.org/x/crypto/bcrypt" 10 | ) 11 | 12 | type IUserUseCase interface { 13 | GetAll() (*[]userDomain.User, error) 14 | GetByID(id int) (*userDomain.User, error) 15 | GetByEmail(email string) (*userDomain.User, error) 16 | Create(newUser *userDomain.User) (*userDomain.User, error) 17 | Delete(id int) error 18 | Update(id int, userMap map[string]interface{}) (*userDomain.User, error) 19 | SearchPaginated(filters domain.DataFilters) (*userDomain.SearchResultUser, error) 20 | SearchByProperty(property string, searchText string) (*[]string, error) 21 | } 22 | 23 | type UserUseCase struct { 24 | userRepository user.UserRepositoryInterface 25 | Logger *logger.Logger 26 | } 27 | 28 | func NewUserUseCase(userRepository user.UserRepositoryInterface, logger *logger.Logger) IUserUseCase { 29 | return &UserUseCase{ 30 | userRepository: userRepository, 31 | Logger: logger, 32 | } 33 | } 34 | 35 | func (s *UserUseCase) GetAll() (*[]userDomain.User, error) { 36 | s.Logger.Info("Getting all users") 37 | return s.userRepository.GetAll() 38 | } 39 | 40 | func (s *UserUseCase) GetByID(id int) (*userDomain.User, error) { 41 | s.Logger.Info("Getting user by ID", zap.Int("id", id)) 42 | return s.userRepository.GetByID(id) 43 | } 44 | 45 | func (s *UserUseCase) GetByEmail(email string) (*userDomain.User, error) { 46 | s.Logger.Info("Getting user by email", zap.String("email", email)) 47 | return s.userRepository.GetByEmail(email) 48 | } 49 | 50 | func (s *UserUseCase) Create(newUser *userDomain.User) (*userDomain.User, error) { 51 | s.Logger.Info("Creating new user", zap.String("email", newUser.Email)) 52 | hash, err := bcrypt.GenerateFromPassword([]byte(newUser.Password), bcrypt.DefaultCost) 53 | if err != nil { 54 | s.Logger.Error("Error hashing password", zap.Error(err)) 55 | return &userDomain.User{}, err 56 | } 57 | newUser.HashPassword = string(hash) 58 | newUser.Status = true 59 | 60 | return s.userRepository.Create(newUser) 61 | } 62 | 63 | func (s *UserUseCase) Delete(id int) error { 64 | s.Logger.Info("Deleting user", zap.Int("id", id)) 65 | return s.userRepository.Delete(id) 66 | } 67 | 68 | func (s *UserUseCase) Update(id int, userMap map[string]interface{}) (*userDomain.User, error) { 69 | s.Logger.Info("Updating user", zap.Int("id", id)) 70 | return s.userRepository.Update(id, userMap) 71 | } 72 | 73 | func (s *UserUseCase) SearchPaginated(filters domain.DataFilters) (*userDomain.SearchResultUser, error) { 74 | s.Logger.Info("Searching users with pagination", 75 | zap.Int("page", filters.Page), 76 | zap.Int("pageSize", filters.PageSize)) 77 | return s.userRepository.SearchPaginated(filters) 78 | } 79 | 80 | func (s *UserUseCase) SearchByProperty(property string, searchText string) (*[]string, error) { 81 | s.Logger.Info("Searching users by property", 82 | zap.String("property", property), 83 | zap.String("searchText", searchText)) 84 | return s.userRepository.SearchByProperty(property, searchText) 85 | } 86 | -------------------------------------------------------------------------------- /src/application/usecases/user/user_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/gbrayhan/microservices-go/src/domain" 9 | userDomain "github.com/gbrayhan/microservices-go/src/domain/user" 10 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 11 | ) 12 | 13 | type mockUserService struct { 14 | getAllFn func() (*[]userDomain.User, error) 15 | getByIDFn func(id int) (*userDomain.User, error) 16 | getByEmailFn func(email string) (*userDomain.User, error) 17 | createFn func(u *userDomain.User) (*userDomain.User, error) 18 | deleteFn func(id int) error 19 | updateFn func(id int, m map[string]interface{}) (*userDomain.User, error) 20 | } 21 | 22 | func (m *mockUserService) GetAll() (*[]userDomain.User, error) { 23 | return m.getAllFn() 24 | } 25 | func (m *mockUserService) GetByID(id int) (*userDomain.User, error) { 26 | return m.getByIDFn(id) 27 | } 28 | func (m *mockUserService) GetByEmail(email string) (*userDomain.User, error) { 29 | return m.getByEmailFn(email) 30 | } 31 | func (m *mockUserService) Create(newUser *userDomain.User) (*userDomain.User, error) { 32 | return m.createFn(newUser) 33 | } 34 | func (m *mockUserService) Delete(id int) error { 35 | return m.deleteFn(id) 36 | } 37 | func (m *mockUserService) Update(id int, userMap map[string]interface{}) (*userDomain.User, error) { 38 | return m.updateFn(id, userMap) 39 | } 40 | func (m *mockUserService) SearchPaginated(filters domain.DataFilters) (*userDomain.SearchResultUser, error) { 41 | return nil, nil 42 | } 43 | func (m *mockUserService) SearchByProperty(property string, searchText string) (*[]string, error) { 44 | return nil, nil 45 | } 46 | 47 | func setupLogger(t *testing.T) *logger.Logger { 48 | loggerInstance, err := logger.NewLogger() 49 | if err != nil { 50 | t.Fatalf("Failed to create logger: %v", err) 51 | } 52 | return loggerInstance 53 | } 54 | 55 | func TestUserUseCase(t *testing.T) { 56 | 57 | mockRepo := &mockUserService{} 58 | logger := setupLogger(t) 59 | useCase := NewUserUseCase(mockRepo, logger) 60 | 61 | t.Run("Test GetAll", func(t *testing.T) { 62 | mockRepo.getAllFn = func() (*[]userDomain.User, error) { 63 | return &[]userDomain.User{{ID: 1}}, nil 64 | } 65 | us, err := useCase.GetAll() 66 | if err != nil { 67 | t.Errorf("unexpected error: %v", err) 68 | } 69 | if len(*us) != 1 { 70 | t.Error("expected 1 user from GetAll") 71 | } 72 | }) 73 | 74 | t.Run("Test GetByID", func(t *testing.T) { 75 | mockRepo.getByIDFn = func(id int) (*userDomain.User, error) { 76 | if id == 999 { 77 | return nil, errors.New("not found") 78 | } 79 | return &userDomain.User{ID: id}, nil 80 | } 81 | _, err := useCase.GetByID(999) 82 | if err == nil { 83 | t.Error("expected error, got nil") 84 | } 85 | u, err := useCase.GetByID(10) 86 | if err != nil { 87 | t.Errorf("unexpected error: %v", err) 88 | } 89 | if u.ID != 10 { 90 | t.Errorf("expected user ID=10, got %d", u.ID) 91 | } 92 | }) 93 | 94 | t.Run("Test GetByEmail", func(t *testing.T) { 95 | mockRepo.getByEmailFn = func(email string) (*userDomain.User, error) { 96 | if email == "notfound@example.com" { 97 | return nil, errors.New("not found") 98 | } 99 | return &userDomain.User{ID: 123, Email: email}, nil 100 | } 101 | _, err := useCase.GetByEmail("notfound@example.com") 102 | if err == nil { 103 | t.Error("expected error, got nil") 104 | } 105 | u, err := useCase.GetByEmail("test@example.com") 106 | if err != nil { 107 | t.Errorf("unexpected error: %v", err) 108 | } 109 | if u.ID != 123 { 110 | t.Errorf("expected user ID=123, got %d", u.ID) 111 | } 112 | if u.Email != "test@example.com" { 113 | t.Errorf("expected email=test@example.com, got %s", u.Email) 114 | } 115 | }) 116 | 117 | t.Run("Test Create (OK)", func(t *testing.T) { 118 | mockRepo.createFn = func(newU *userDomain.User) (*userDomain.User, error) { 119 | if !newU.Status { 120 | t.Error("expected user.Status to be true") 121 | } 122 | if newU.HashPassword == "" { 123 | t.Error("expected user.HashPassword to be set") 124 | } 125 | if newU.Email == "" { 126 | return nil, errors.New("bad data") 127 | } 128 | newU.ID = 555 129 | return newU, nil 130 | } 131 | created, err := useCase.Create(&userDomain.User{Email: "test@mail.com", Password: "abc"}) 132 | if err != nil { 133 | t.Errorf("unexpected error: %v", err) 134 | } 135 | if created.ID != 555 { 136 | t.Error("expected ID=555 after create") 137 | } 138 | }) 139 | 140 | t.Run("Test Create (Error empty email)", func(t *testing.T) { 141 | _, err := useCase.Create(&userDomain.User{Email: "", Password: "abc"}) 142 | if err == nil { 143 | t.Error("expected error on create user with empty email") 144 | } 145 | }) 146 | 147 | t.Run("Test Delete", func(t *testing.T) { 148 | mockRepo.deleteFn = func(id int) error { 149 | if id == 101 { 150 | return nil 151 | } 152 | return errors.New("cannot delete") 153 | } 154 | err := useCase.Delete(999) 155 | if err == nil { 156 | t.Error("expected error for cannot delete") 157 | } 158 | err = useCase.Delete(101) 159 | if err != nil { 160 | t.Errorf("unexpected error: %v", err) 161 | } 162 | }) 163 | 164 | t.Run("Test Update", func(t *testing.T) { 165 | mockRepo.updateFn = func(id int, m map[string]interface{}) (*userDomain.User, error) { 166 | if id != 1001 { 167 | return nil, errors.New("not found") 168 | } 169 | return &userDomain.User{ID: id, UserName: "Updated"}, nil 170 | } 171 | _, err := useCase.Update(999, map[string]interface{}{"userName": "any"}) 172 | if err == nil { 173 | t.Error("expected error, got nil") 174 | } 175 | updated, err := useCase.Update(1001, map[string]interface{}{"userName": "whatever"}) 176 | if err != nil { 177 | t.Errorf("unexpected error: %v", err) 178 | } 179 | if updated.UserName != "Updated" { 180 | t.Error("expected userName=Updated") 181 | } 182 | }) 183 | } 184 | 185 | func TestNewUserUseCase(t *testing.T) { 186 | mockRepo := &mockUserService{} 187 | loggerInstance := setupLogger(t) 188 | useCase := NewUserUseCase(mockRepo, loggerInstance) 189 | if reflect.TypeOf(useCase).String() != "*user.UserUseCase" { 190 | t.Error("expected *user.UserUseCase type") 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/domain/Types.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "time" 4 | 5 | type DateRangeFilter struct { 6 | Field string `json:"field"` 7 | Start *time.Time `json:"start"` 8 | End *time.Time `json:"end"` 9 | } 10 | 11 | type SortDirection string 12 | 13 | const ( 14 | SortAsc SortDirection = "asc" 15 | SortDesc SortDirection = "desc" 16 | ) 17 | 18 | func (sd SortDirection) IsValid() bool { 19 | return sd == SortAsc || sd == SortDesc 20 | } 21 | 22 | type DataFilters struct { 23 | LikeFilters map[string][]string `json:"likeFilters"` 24 | Matches map[string][]string `json:"matches"` 25 | DateRangeFilters []DateRangeFilter `json:"dateRanges"` 26 | SortBy []string `json:"sortBy"` 27 | SortDirection SortDirection `json:"sortDirection"` 28 | Page int `json:"page"` 29 | PageSize int `json:"pageSize"` 30 | } 31 | -------------------------------------------------------------------------------- /src/domain/errors/Errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | ) 7 | 8 | type ErrorType string 9 | type ErrorMessage string 10 | 11 | const ( 12 | NotFound ErrorType = "NotFound" 13 | notFoundMessage ErrorMessage = "record not found" 14 | 15 | ValidationError ErrorType = "ValidationError" 16 | validationErrorMessage ErrorMessage = "validation error" 17 | 18 | ResourceAlreadyExists ErrorType = "ResourceAlreadyExists" 19 | alreadyExistsErrorMessage ErrorMessage = "resource already exists" 20 | 21 | RepositoryError ErrorType = "RepositoryError" 22 | repositoryErrorMessage ErrorMessage = "error in repository operation" 23 | 24 | NotAuthenticated ErrorType = "NotAuthenticated" 25 | notAuthenticatedErrorMessage ErrorMessage = "not Authenticated" 26 | 27 | TokenGeneratorError ErrorType = "TokenGeneratorError" 28 | tokenGeneratorErrorMessage ErrorMessage = "error in token generation" 29 | 30 | NotAuthorized ErrorType = "NotAuthorized" 31 | notAuthorizedErrorMessage ErrorMessage = "not authorized" 32 | 33 | UnknownError ErrorType = "UnknownError" 34 | unknownErrorMessage ErrorMessage = "something went wrong" 35 | ) 36 | 37 | type AppError struct { 38 | Err error 39 | Type ErrorType 40 | } 41 | 42 | func NewAppError(err error, errType ErrorType) *AppError { 43 | return &AppError{ 44 | Err: err, 45 | Type: errType, 46 | } 47 | } 48 | 49 | func NewAppErrorWithType(errType ErrorType) *AppError { 50 | var err error 51 | 52 | switch errType { 53 | case NotFound: 54 | err = errors.New(string(notFoundMessage)) 55 | case ValidationError: 56 | err = errors.New(string(validationErrorMessage)) 57 | case ResourceAlreadyExists: 58 | err = errors.New(string(alreadyExistsErrorMessage)) 59 | case RepositoryError: 60 | err = errors.New(string(repositoryErrorMessage)) 61 | case NotAuthenticated: 62 | err = errors.New(string(notAuthenticatedErrorMessage)) 63 | case NotAuthorized: 64 | err = errors.New(string(notAuthorizedErrorMessage)) 65 | case TokenGeneratorError: 66 | err = errors.New(string(tokenGeneratorErrorMessage)) 67 | default: 68 | err = errors.New(string(unknownErrorMessage)) 69 | } 70 | 71 | return &AppError{ 72 | Err: err, 73 | Type: errType, 74 | } 75 | } 76 | 77 | func (appErr *AppError) Error() string { 78 | return appErr.Err.Error() 79 | } 80 | 81 | // AppErrorToHTTP maps an AppError to an HTTP status code and message 82 | func AppErrorToHTTP(appErr *AppError) (int, string) { 83 | switch appErr.Type { 84 | case NotFound: 85 | return http.StatusNotFound, appErr.Error() 86 | case ValidationError: 87 | return http.StatusBadRequest, appErr.Error() 88 | case RepositoryError: 89 | return http.StatusInternalServerError, appErr.Error() 90 | case NotAuthenticated: 91 | return http.StatusUnauthorized, appErr.Error() 92 | case NotAuthorized: 93 | return http.StatusForbidden, appErr.Error() 94 | default: 95 | return http.StatusInternalServerError, "Internal Server Error" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/domain/errors/Errors_test.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNewAppError(t *testing.T) { 12 | originalError := errors.New("original error") 13 | appError := NewAppError(originalError, ValidationError) 14 | 15 | assert.NotNil(t, appError) 16 | assert.Equal(t, originalError, appError.Err) 17 | assert.Equal(t, ValidationError, appError.Type) 18 | } 19 | 20 | func TestNewAppErrorWithType_NotFound(t *testing.T) { 21 | appError := NewAppErrorWithType(NotFound) 22 | 23 | assert.NotNil(t, appError) 24 | assert.Equal(t, NotFound, appError.Type) 25 | assert.Equal(t, "record not found", appError.Error()) 26 | } 27 | 28 | func TestNewAppErrorWithType_ValidationError(t *testing.T) { 29 | appError := NewAppErrorWithType(ValidationError) 30 | 31 | assert.NotNil(t, appError) 32 | assert.Equal(t, ValidationError, appError.Type) 33 | assert.Equal(t, "validation error", appError.Error()) 34 | } 35 | 36 | func TestNewAppErrorWithType_ResourceAlreadyExists(t *testing.T) { 37 | appError := NewAppErrorWithType(ResourceAlreadyExists) 38 | 39 | assert.NotNil(t, appError) 40 | assert.Equal(t, ResourceAlreadyExists, appError.Type) 41 | assert.Equal(t, "resource already exists", appError.Error()) 42 | } 43 | 44 | func TestNewAppErrorWithType_RepositoryError(t *testing.T) { 45 | appError := NewAppErrorWithType(RepositoryError) 46 | 47 | assert.NotNil(t, appError) 48 | assert.Equal(t, RepositoryError, appError.Type) 49 | assert.Equal(t, "error in repository operation", appError.Error()) 50 | } 51 | 52 | func TestNewAppErrorWithType_NotAuthenticated(t *testing.T) { 53 | appError := NewAppErrorWithType(NotAuthenticated) 54 | 55 | assert.NotNil(t, appError) 56 | assert.Equal(t, NotAuthenticated, appError.Type) 57 | assert.Equal(t, "not Authenticated", appError.Error()) 58 | } 59 | 60 | func TestNewAppErrorWithType_NotAuthorized(t *testing.T) { 61 | appError := NewAppErrorWithType(NotAuthorized) 62 | 63 | assert.NotNil(t, appError) 64 | assert.Equal(t, NotAuthorized, appError.Type) 65 | assert.Equal(t, "not authorized", appError.Error()) 66 | } 67 | 68 | func TestNewAppErrorWithType_TokenGeneratorError(t *testing.T) { 69 | appError := NewAppErrorWithType(TokenGeneratorError) 70 | 71 | assert.NotNil(t, appError) 72 | assert.Equal(t, TokenGeneratorError, appError.Type) 73 | assert.Equal(t, "error in token generation", appError.Error()) 74 | } 75 | 76 | func TestNewAppErrorWithType_UnknownError(t *testing.T) { 77 | appError := NewAppErrorWithType(UnknownError) 78 | 79 | assert.NotNil(t, appError) 80 | assert.Equal(t, UnknownError, appError.Type) 81 | assert.Equal(t, "something went wrong", appError.Error()) 82 | } 83 | 84 | func TestNewAppErrorWithType_InvalidType(t *testing.T) { 85 | appError := NewAppErrorWithType("InvalidType") 86 | 87 | assert.NotNil(t, appError) 88 | assert.Equal(t, ErrorType("InvalidType"), appError.Type) 89 | assert.Equal(t, "something went wrong", appError.Error()) // Should default to unknown error 90 | } 91 | 92 | func TestAppError_Error(t *testing.T) { 93 | originalError := errors.New("test error message") 94 | appError := &AppError{ 95 | Err: originalError, 96 | Type: ValidationError, 97 | } 98 | 99 | assert.Equal(t, "test error message", appError.Error()) 100 | } 101 | 102 | func TestAppErrorToHTTP_NotFound(t *testing.T) { 103 | appError := NewAppErrorWithType(NotFound) 104 | statusCode, message := AppErrorToHTTP(appError) 105 | 106 | assert.Equal(t, http.StatusNotFound, statusCode) 107 | assert.Equal(t, "record not found", message) 108 | } 109 | 110 | func TestAppErrorToHTTP_ValidationError(t *testing.T) { 111 | appError := NewAppErrorWithType(ValidationError) 112 | statusCode, message := AppErrorToHTTP(appError) 113 | 114 | assert.Equal(t, http.StatusBadRequest, statusCode) 115 | assert.Equal(t, "validation error", message) 116 | } 117 | 118 | func TestAppErrorToHTTP_RepositoryError(t *testing.T) { 119 | appError := NewAppErrorWithType(RepositoryError) 120 | statusCode, message := AppErrorToHTTP(appError) 121 | 122 | assert.Equal(t, http.StatusInternalServerError, statusCode) 123 | assert.Equal(t, "error in repository operation", message) 124 | } 125 | 126 | func TestAppErrorToHTTP_NotAuthenticated(t *testing.T) { 127 | appError := NewAppErrorWithType(NotAuthenticated) 128 | statusCode, message := AppErrorToHTTP(appError) 129 | 130 | assert.Equal(t, http.StatusUnauthorized, statusCode) 131 | assert.Equal(t, "not Authenticated", message) 132 | } 133 | 134 | func TestAppErrorToHTTP_NotAuthorized(t *testing.T) { 135 | appError := NewAppErrorWithType(NotAuthorized) 136 | statusCode, message := AppErrorToHTTP(appError) 137 | 138 | assert.Equal(t, http.StatusForbidden, statusCode) 139 | assert.Equal(t, "not authorized", message) 140 | } 141 | 142 | func TestAppErrorToHTTP_UnknownError(t *testing.T) { 143 | appError := NewAppErrorWithType(UnknownError) 144 | statusCode, message := AppErrorToHTTP(appError) 145 | 146 | assert.Equal(t, http.StatusInternalServerError, statusCode) 147 | assert.Equal(t, "Internal Server Error", message) 148 | } 149 | 150 | func TestAppErrorToHTTP_ResourceAlreadyExists(t *testing.T) { 151 | appError := NewAppErrorWithType(ResourceAlreadyExists) 152 | statusCode, message := AppErrorToHTTP(appError) 153 | 154 | assert.Equal(t, http.StatusInternalServerError, statusCode) 155 | assert.Equal(t, "Internal Server Error", message) 156 | } 157 | 158 | func TestAppErrorToHTTP_TokenGeneratorError(t *testing.T) { 159 | appError := NewAppErrorWithType(TokenGeneratorError) 160 | statusCode, message := AppErrorToHTTP(appError) 161 | 162 | assert.Equal(t, http.StatusInternalServerError, statusCode) 163 | assert.Equal(t, "Internal Server Error", message) 164 | } 165 | 166 | func TestAppErrorToHTTP_CustomError(t *testing.T) { 167 | appError := NewAppError(errors.New("custom error"), "CustomError") 168 | statusCode, message := AppErrorToHTTP(appError) 169 | 170 | assert.Equal(t, http.StatusInternalServerError, statusCode) 171 | assert.Equal(t, "Internal Server Error", message) 172 | } 173 | 174 | func TestErrorTypeConstants(t *testing.T) { 175 | // Test that all error type constants are defined 176 | assert.Equal(t, ErrorType("NotFound"), NotFound) 177 | assert.Equal(t, ErrorType("ValidationError"), ValidationError) 178 | assert.Equal(t, ErrorType("ResourceAlreadyExists"), ResourceAlreadyExists) 179 | assert.Equal(t, ErrorType("RepositoryError"), RepositoryError) 180 | assert.Equal(t, ErrorType("NotAuthenticated"), NotAuthenticated) 181 | assert.Equal(t, ErrorType("NotAuthorized"), NotAuthorized) 182 | assert.Equal(t, ErrorType("TokenGeneratorError"), TokenGeneratorError) 183 | assert.Equal(t, ErrorType("UnknownError"), UnknownError) 184 | } 185 | -------------------------------------------------------------------------------- /src/domain/errors/Gorm.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | type GormErr struct { 4 | Number int `json:"Number"` 5 | Message string `json:"Message"` 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/medicine/Medicine.go: -------------------------------------------------------------------------------- 1 | package medicine 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gbrayhan/microservices-go/src/domain" 7 | ) 8 | 9 | type Medicine struct { 10 | ID int 11 | Name string 12 | Description string 13 | EanCode string 14 | Laboratory string 15 | CreatedAt time.Time 16 | UpdatedAt time.Time 17 | } 18 | 19 | type DataMedicine struct { 20 | Data *[]Medicine 21 | Total int64 22 | } 23 | 24 | type SearchResultMedicine struct { 25 | Data *[]Medicine 26 | Total int64 27 | Page int 28 | PageSize int 29 | TotalPages int 30 | } 31 | 32 | type IMedicineService interface { 33 | GetAll() (*[]Medicine, error) 34 | GetByID(id int) (*Medicine, error) 35 | Create(medicine *Medicine) (*Medicine, error) 36 | Delete(id int) error 37 | Update(id int, medicineMap map[string]any) (*Medicine, error) 38 | SearchPaginated(filters domain.DataFilters) (*SearchResultMedicine, error) 39 | SearchByProperty(property string, searchText string) (*[]string, error) 40 | } 41 | -------------------------------------------------------------------------------- /src/domain/medicine/medicine_test.go: -------------------------------------------------------------------------------- 1 | package medicine 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestMedicine_Fields(t *testing.T) { 9 | medicine := Medicine{ 10 | ID: 1, 11 | Name: "Test Medicine", 12 | Description: "Test Description", 13 | EanCode: "1234567890123", 14 | Laboratory: "Test Lab", 15 | CreatedAt: time.Now(), 16 | UpdatedAt: time.Now(), 17 | } 18 | 19 | if medicine.ID != 1 { 20 | t.Errorf("Expected ID to be 1, got %d", medicine.ID) 21 | } 22 | 23 | if medicine.Name != "Test Medicine" { 24 | t.Errorf("Expected Name to be 'Test Medicine', got %s", medicine.Name) 25 | } 26 | 27 | if medicine.Description != "Test Description" { 28 | t.Errorf("Expected Description to be 'Test Description', got %s", medicine.Description) 29 | } 30 | 31 | if medicine.EanCode != "1234567890123" { 32 | t.Errorf("Expected EanCode to be '1234567890123', got %s", medicine.EanCode) 33 | } 34 | 35 | if medicine.Laboratory != "Test Lab" { 36 | t.Errorf("Expected Laboratory to be 'Test Lab', got %s", medicine.Laboratory) 37 | } 38 | } 39 | 40 | func TestMedicine_TimeFields(t *testing.T) { 41 | now := time.Now() 42 | medicine := Medicine{ 43 | CreatedAt: now, 44 | UpdatedAt: now, 45 | } 46 | 47 | if !medicine.CreatedAt.Equal(now) { 48 | t.Errorf("Expected CreatedAt to be %v, got %v", now, medicine.CreatedAt) 49 | } 50 | 51 | if !medicine.UpdatedAt.Equal(now) { 52 | t.Errorf("Expected UpdatedAt to be %v, got %v", now, medicine.UpdatedAt) 53 | } 54 | } 55 | 56 | func TestMedicine_ZeroValues(t *testing.T) { 57 | medicine := Medicine{} 58 | 59 | if medicine.ID != 0 { 60 | t.Errorf("Expected ID to be 0, got %d", medicine.ID) 61 | } 62 | 63 | if medicine.Name != "" { 64 | t.Errorf("Expected Name to be empty, got %s", medicine.Name) 65 | } 66 | 67 | if medicine.Description != "" { 68 | t.Errorf("Expected Description to be empty, got %s", medicine.Description) 69 | } 70 | 71 | if medicine.EanCode != "" { 72 | t.Errorf("Expected EanCode to be empty, got %s", medicine.EanCode) 73 | } 74 | 75 | if medicine.Laboratory != "" { 76 | t.Errorf("Expected Laboratory to be empty, got %s", medicine.Laboratory) 77 | } 78 | 79 | if !medicine.CreatedAt.IsZero() { 80 | t.Errorf("Expected CreatedAt to be zero, got %v", medicine.CreatedAt) 81 | } 82 | 83 | if !medicine.UpdatedAt.IsZero() { 84 | t.Errorf("Expected UpdatedAt to be zero, got %v", medicine.UpdatedAt) 85 | } 86 | } 87 | 88 | func TestDataMedicine_Fields(t *testing.T) { 89 | medicines := []Medicine{ 90 | {ID: 1, Name: "Medicine 1"}, 91 | {ID: 2, Name: "Medicine 2"}, 92 | } 93 | 94 | dataMedicine := DataMedicine{ 95 | Data: &medicines, 96 | Total: 2, 97 | } 98 | 99 | if len(*dataMedicine.Data) != 2 { 100 | t.Errorf("Expected Data length to be 2, got %d", len(*dataMedicine.Data)) 101 | } 102 | 103 | if dataMedicine.Total != 2 { 104 | t.Errorf("Expected Total to be 2, got %d", dataMedicine.Total) 105 | } 106 | 107 | if (*dataMedicine.Data)[0].ID != 1 { 108 | t.Errorf("Expected first medicine ID to be 1, got %d", (*dataMedicine.Data)[0].ID) 109 | } 110 | 111 | if (*dataMedicine.Data)[1].ID != 2 { 112 | t.Errorf("Expected second medicine ID to be 2, got %d", (*dataMedicine.Data)[1].ID) 113 | } 114 | } 115 | 116 | func TestDataMedicine_ZeroValues(t *testing.T) { 117 | dataMedicine := DataMedicine{} 118 | 119 | if dataMedicine.Data != nil { 120 | t.Errorf("Expected Data to be nil, got %v", dataMedicine.Data) 121 | } 122 | 123 | if dataMedicine.Total != 0 { 124 | t.Errorf("Expected Total to be 0, got %d", dataMedicine.Total) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/domain/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gbrayhan/microservices-go/src/domain" 7 | ) 8 | 9 | type User struct { 10 | ID int 11 | UserName string 12 | Email string 13 | FirstName string 14 | LastName string 15 | Status bool 16 | HashPassword string 17 | Password string 18 | CreatedAt time.Time 19 | UpdatedAt time.Time 20 | } 21 | 22 | type SearchResultUser struct { 23 | Data *[]User 24 | Total int64 25 | Page int 26 | PageSize int 27 | TotalPages int 28 | } 29 | 30 | type IUserService interface { 31 | GetAll() (*[]User, error) 32 | GetByID(id int) (*User, error) 33 | Create(newUser *User) (*User, error) 34 | Delete(id int) error 35 | Update(id int, userMap map[string]interface{}) (*User, error) 36 | SearchPaginated(filters domain.DataFilters) (*SearchResultUser, error) 37 | SearchByProperty(property string, searchText string) (*[]string, error) 38 | } 39 | -------------------------------------------------------------------------------- /src/domain/user/user_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestUser_Fields(t *testing.T) { 9 | user := User{ 10 | ID: 1, 11 | UserName: "testuser", 12 | Email: "test@example.com", 13 | FirstName: "Test", 14 | LastName: "User", 15 | Status: true, 16 | HashPassword: "hashedpassword", 17 | Password: "password", 18 | CreatedAt: time.Now(), 19 | UpdatedAt: time.Now(), 20 | } 21 | 22 | if user.ID != 1 { 23 | t.Errorf("Expected ID to be 1, got %d", user.ID) 24 | } 25 | 26 | if user.UserName != "testuser" { 27 | t.Errorf("Expected UserName to be 'testuser', got %s", user.UserName) 28 | } 29 | 30 | if user.Email != "test@example.com" { 31 | t.Errorf("Expected Email to be 'test@example.com', got %s", user.Email) 32 | } 33 | 34 | if user.FirstName != "Test" { 35 | t.Errorf("Expected FirstName to be 'Test', got %s", user.FirstName) 36 | } 37 | 38 | if user.LastName != "User" { 39 | t.Errorf("Expected LastName to be 'User', got %s", user.LastName) 40 | } 41 | 42 | if !user.Status { 43 | t.Errorf("Expected Status to be true, got %t", user.Status) 44 | } 45 | 46 | if user.HashPassword != "hashedpassword" { 47 | t.Errorf("Expected HashPassword to be 'hashedpassword', got %s", user.HashPassword) 48 | } 49 | 50 | if user.Password != "password" { 51 | t.Errorf("Expected Password to be 'password', got %s", user.Password) 52 | } 53 | } 54 | 55 | func TestUser_TimeFields(t *testing.T) { 56 | now := time.Now() 57 | user := User{ 58 | CreatedAt: now, 59 | UpdatedAt: now, 60 | } 61 | 62 | if !user.CreatedAt.Equal(now) { 63 | t.Errorf("Expected CreatedAt to be %v, got %v", now, user.CreatedAt) 64 | } 65 | 66 | if !user.UpdatedAt.Equal(now) { 67 | t.Errorf("Expected UpdatedAt to be %v, got %v", now, user.UpdatedAt) 68 | } 69 | } 70 | 71 | func TestUser_ZeroValues(t *testing.T) { 72 | user := User{} 73 | 74 | if user.ID != 0 { 75 | t.Errorf("Expected ID to be 0, got %d", user.ID) 76 | } 77 | 78 | if user.UserName != "" { 79 | t.Errorf("Expected UserName to be empty, got %s", user.UserName) 80 | } 81 | 82 | if user.Email != "" { 83 | t.Errorf("Expected Email to be empty, got %s", user.Email) 84 | } 85 | 86 | if user.FirstName != "" { 87 | t.Errorf("Expected FirstName to be empty, got %s", user.FirstName) 88 | } 89 | 90 | if user.LastName != "" { 91 | t.Errorf("Expected LastName to be empty, got %s", user.LastName) 92 | } 93 | 94 | if user.Status { 95 | t.Errorf("Expected Status to be false, got %t", user.Status) 96 | } 97 | 98 | if user.HashPassword != "" { 99 | t.Errorf("Expected HashPassword to be empty, got %s", user.HashPassword) 100 | } 101 | 102 | if user.Password != "" { 103 | t.Errorf("Expected Password to be empty, got %s", user.Password) 104 | } 105 | 106 | if !user.CreatedAt.IsZero() { 107 | t.Errorf("Expected CreatedAt to be zero, got %v", user.CreatedAt) 108 | } 109 | 110 | if !user.UpdatedAt.IsZero() { 111 | t.Errorf("Expected UpdatedAt to be zero, got %v", user.UpdatedAt) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/infrastructure/di/application_context.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "sync" 5 | 6 | authUseCase "github.com/gbrayhan/microservices-go/src/application/usecases/auth" 7 | medicineUseCase "github.com/gbrayhan/microservices-go/src/application/usecases/medicine" 8 | userUseCase "github.com/gbrayhan/microservices-go/src/application/usecases/user" 9 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 10 | "github.com/gbrayhan/microservices-go/src/infrastructure/repository/psql" 11 | "github.com/gbrayhan/microservices-go/src/infrastructure/repository/psql/medicine" 12 | "github.com/gbrayhan/microservices-go/src/infrastructure/repository/psql/user" 13 | authController "github.com/gbrayhan/microservices-go/src/infrastructure/rest/controllers/auth" 14 | medicineController "github.com/gbrayhan/microservices-go/src/infrastructure/rest/controllers/medicine" 15 | userController "github.com/gbrayhan/microservices-go/src/infrastructure/rest/controllers/user" 16 | "github.com/gbrayhan/microservices-go/src/infrastructure/security" 17 | "gorm.io/gorm" 18 | ) 19 | 20 | // ApplicationContext holds all application dependencies and services 21 | type ApplicationContext struct { 22 | DB *gorm.DB 23 | Logger *logger.Logger 24 | AuthController authController.IAuthController 25 | UserController userController.IUserController 26 | MedicineController medicineController.IMedicineController 27 | JWTService security.IJWTService 28 | UserRepository user.UserRepositoryInterface 29 | MedicineRepository medicine.MedicineRepositoryInterface 30 | AuthUseCase authUseCase.IAuthUseCase 31 | UserUseCase userUseCase.IUserUseCase 32 | MedicineUseCase medicineUseCase.IMedicineUseCase 33 | } 34 | 35 | var ( 36 | loggerInstance *logger.Logger 37 | loggerOnce sync.Once 38 | ) 39 | 40 | func GetLogger() *logger.Logger { 41 | loggerOnce.Do(func() { 42 | loggerInstance, _ = logger.NewLogger() 43 | }) 44 | return loggerInstance 45 | } 46 | 47 | // SetupDependencies creates a new application context with all dependencies 48 | func SetupDependencies(loggerInstance *logger.Logger) (*ApplicationContext, error) { 49 | // Initialize database with logger 50 | db, err := psql.InitPSQLDB(loggerInstance) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | // Initialize JWT service (manages its own configuration) 56 | jwtService := security.NewJWTService() 57 | 58 | // Initialize repositories with logger 59 | userRepo := user.NewUserRepository(db, loggerInstance) 60 | medicineRepo := medicine.NewMedicineRepository(db, loggerInstance) 61 | 62 | // Initialize use cases with logger 63 | authUC := authUseCase.NewAuthUseCase(userRepo, jwtService, loggerInstance) 64 | userUC := userUseCase.NewUserUseCase(userRepo, loggerInstance) 65 | medicineUC := medicineUseCase.NewMedicineUseCase(medicineRepo, loggerInstance) 66 | 67 | // Initialize controllers with logger 68 | authController := authController.NewAuthController(authUC, loggerInstance) 69 | userController := userController.NewUserController(userUC, loggerInstance) 70 | medicineController := medicineController.NewMedicineController(medicineUC, loggerInstance) 71 | 72 | return &ApplicationContext{ 73 | DB: db, 74 | Logger: loggerInstance, 75 | AuthController: authController, 76 | UserController: userController, 77 | MedicineController: medicineController, 78 | JWTService: jwtService, 79 | UserRepository: userRepo, 80 | MedicineRepository: medicineRepo, 81 | AuthUseCase: authUC, 82 | UserUseCase: userUC, 83 | MedicineUseCase: medicineUC, 84 | }, nil 85 | } 86 | 87 | // NewTestApplicationContext creates an application context for testing with mocked dependencies 88 | func NewTestApplicationContext( 89 | mockUserRepo user.UserRepositoryInterface, 90 | mockMedicineRepo medicine.MedicineRepositoryInterface, 91 | mockJWTService security.IJWTService, 92 | loggerInstance *logger.Logger, 93 | ) *ApplicationContext { 94 | // Initialize use cases with mocked repositories and logger 95 | authUC := authUseCase.NewAuthUseCase(mockUserRepo, mockJWTService, loggerInstance) 96 | userUC := userUseCase.NewUserUseCase(mockUserRepo, loggerInstance) 97 | medicineUC := medicineUseCase.NewMedicineUseCase(mockMedicineRepo, loggerInstance) 98 | 99 | // Initialize controllers with logger 100 | authController := authController.NewAuthController(authUC, loggerInstance) 101 | userController := userController.NewUserController(userUC, loggerInstance) 102 | medicineController := medicineController.NewMedicineController(medicineUC, loggerInstance) 103 | 104 | return &ApplicationContext{ 105 | Logger: loggerInstance, 106 | AuthController: authController, 107 | UserController: userController, 108 | MedicineController: medicineController, 109 | JWTService: mockJWTService, 110 | UserRepository: mockUserRepo, 111 | MedicineRepository: mockMedicineRepo, 112 | AuthUseCase: authUC, 113 | UserUseCase: userUC, 114 | MedicineUseCase: medicineUC, 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/infrastructure/di/application_context_test.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/gbrayhan/microservices-go/src/domain" 8 | domainMedicine "github.com/gbrayhan/microservices-go/src/domain/medicine" 9 | domainUser "github.com/gbrayhan/microservices-go/src/domain/user" 10 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 11 | "github.com/gbrayhan/microservices-go/src/infrastructure/security" 12 | "github.com/golang-jwt/jwt/v4" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/mock" 15 | ) 16 | 17 | // Mock repositories and services 18 | type MockUserRepository struct { 19 | mock.Mock 20 | } 21 | 22 | func (m *MockUserRepository) GetAll() (*[]domainUser.User, error) { 23 | args := m.Called() 24 | return args.Get(0).(*[]domainUser.User), args.Error(1) 25 | } 26 | 27 | func (m *MockUserRepository) GetByID(id int) (*domainUser.User, error) { 28 | args := m.Called(id) 29 | return args.Get(0).(*domainUser.User), args.Error(1) 30 | } 31 | 32 | func (m *MockUserRepository) Create(user *domainUser.User) (*domainUser.User, error) { 33 | args := m.Called(user) 34 | return args.Get(0).(*domainUser.User), args.Error(1) 35 | } 36 | 37 | func (m *MockUserRepository) GetByEmail(email string) (*domainUser.User, error) { 38 | args := m.Called(email) 39 | return args.Get(0).(*domainUser.User), args.Error(1) 40 | } 41 | 42 | func (m *MockUserRepository) Delete(id int) error { 43 | args := m.Called(id) 44 | return args.Error(0) 45 | } 46 | 47 | func (m *MockUserRepository) Update(id int, userMap map[string]interface{}) (*domainUser.User, error) { 48 | args := m.Called(id, userMap) 49 | return args.Get(0).(*domainUser.User), args.Error(1) 50 | } 51 | 52 | func (m *MockUserRepository) SearchPaginated(filters domain.DataFilters) (*domainUser.SearchResultUser, error) { 53 | args := m.Called(filters) 54 | return args.Get(0).(*domainUser.SearchResultUser), args.Error(1) 55 | } 56 | 57 | func (m *MockUserRepository) SearchByProperty(property string, searchText string) (*[]string, error) { 58 | args := m.Called(property, searchText) 59 | return args.Get(0).(*[]string), args.Error(1) 60 | } 61 | 62 | type MockMedicineRepository struct { 63 | mock.Mock 64 | } 65 | 66 | func (m *MockMedicineRepository) GetAll() (*[]domainMedicine.Medicine, error) { 67 | args := m.Called() 68 | return args.Get(0).(*[]domainMedicine.Medicine), args.Error(1) 69 | } 70 | 71 | func (m *MockMedicineRepository) GetData(page int64, limit int64, sortBy string, sortDirection string, filters map[string][]string, searchText string, dateRangeFilters []domain.DateRangeFilter) (*domainMedicine.DataMedicine, error) { 72 | args := m.Called(page, limit, sortBy, sortDirection, filters, searchText, dateRangeFilters) 73 | return args.Get(0).(*domainMedicine.DataMedicine), args.Error(1) 74 | } 75 | 76 | func (m *MockMedicineRepository) GetByID(id int) (*domainMedicine.Medicine, error) { 77 | args := m.Called(id) 78 | return args.Get(0).(*domainMedicine.Medicine), args.Error(1) 79 | } 80 | 81 | func (m *MockMedicineRepository) Create(medicine *domainMedicine.Medicine) (*domainMedicine.Medicine, error) { 82 | args := m.Called(medicine) 83 | return args.Get(0).(*domainMedicine.Medicine), args.Error(1) 84 | } 85 | 86 | func (m *MockMedicineRepository) GetByMap(medicineMap map[string]any) (*domainMedicine.Medicine, error) { 87 | args := m.Called(medicineMap) 88 | return args.Get(0).(*domainMedicine.Medicine), args.Error(1) 89 | } 90 | 91 | func (m *MockMedicineRepository) Delete(id int) error { 92 | args := m.Called(id) 93 | return args.Error(0) 94 | } 95 | 96 | func (m *MockMedicineRepository) Update(id int, medicineMap map[string]any) (*domainMedicine.Medicine, error) { 97 | args := m.Called(id, medicineMap) 98 | return args.Get(0).(*domainMedicine.Medicine), args.Error(1) 99 | } 100 | 101 | func (m *MockMedicineRepository) SearchPaginated(filters domain.DataFilters) (*domainMedicine.SearchResultMedicine, error) { 102 | args := m.Called(filters) 103 | return args.Get(0).(*domainMedicine.SearchResultMedicine), args.Error(1) 104 | } 105 | 106 | func (m *MockMedicineRepository) SearchByProperty(property string, searchText string) (*[]string, error) { 107 | args := m.Called(property, searchText) 108 | return args.Get(0).(*[]string), args.Error(1) 109 | } 110 | 111 | type MockJWTService struct { 112 | mock.Mock 113 | } 114 | 115 | func (m *MockJWTService) GenerateJWTToken(userID int, tokenType string) (*security.AppToken, error) { 116 | args := m.Called(userID, tokenType) 117 | return args.Get(0).(*security.AppToken), args.Error(1) 118 | } 119 | 120 | func (m *MockJWTService) GetClaimsAndVerifyToken(tokenString string, tokenType string) (jwt.MapClaims, error) { 121 | args := m.Called(tokenString, tokenType) 122 | return args.Get(0).(jwt.MapClaims), args.Error(1) 123 | } 124 | 125 | func setupLogger(t *testing.T) *logger.Logger { 126 | loggerInstance, err := logger.NewLogger() 127 | if err != nil { 128 | t.Fatalf("Failed to create logger: %v", err) 129 | } 130 | return loggerInstance 131 | } 132 | 133 | func TestNewTestApplicationContext(t *testing.T) { 134 | mockUserRepo := &MockUserRepository{} 135 | mockMedicineRepo := &MockMedicineRepository{} 136 | mockJWTService := &MockJWTService{} 137 | logger := setupLogger(t) 138 | 139 | appContext := NewTestApplicationContext(mockUserRepo, mockMedicineRepo, mockJWTService, logger) 140 | 141 | assert.NotNil(t, appContext) 142 | assert.Equal(t, mockUserRepo, appContext.UserRepository) 143 | assert.Equal(t, mockMedicineRepo, appContext.MedicineRepository) 144 | assert.Equal(t, mockJWTService, appContext.JWTService) 145 | 146 | // Test that controllers are created 147 | assert.NotNil(t, appContext.AuthController) 148 | assert.NotNil(t, appContext.UserController) 149 | assert.NotNil(t, appContext.MedicineController) 150 | 151 | // Test that use cases are created 152 | assert.NotNil(t, appContext.AuthUseCase) 153 | assert.NotNil(t, appContext.UserUseCase) 154 | assert.NotNil(t, appContext.MedicineUseCase) 155 | } 156 | 157 | func TestSetupDependencies(t *testing.T) { 158 | // This test will fail in CI/CD without a real database connection 159 | // We'll test the error path by setting invalid environment variables 160 | originalPort := os.Getenv("DB_PORT") 161 | os.Setenv("DB_PORT", "99999") // Invalid port to cause connection failure 162 | defer os.Setenv("DB_PORT", originalPort) 163 | 164 | logger := setupLogger(t) 165 | appContext, err := SetupDependencies(logger) 166 | 167 | assert.Error(t, err) 168 | assert.Nil(t, appContext) 169 | } 170 | 171 | func TestApplicationContextStructure(t *testing.T) { 172 | mockUserRepo := &MockUserRepository{} 173 | mockMedicineRepo := &MockMedicineRepository{} 174 | mockJWTService := &MockJWTService{} 175 | logger := setupLogger(t) 176 | 177 | appContext := NewTestApplicationContext(mockUserRepo, mockMedicineRepo, mockJWTService, logger) 178 | 179 | // Test that all fields are properly set 180 | assert.NotNil(t, appContext.AuthController) 181 | assert.NotNil(t, appContext.UserController) 182 | assert.NotNil(t, appContext.MedicineController) 183 | assert.NotNil(t, appContext.JWTService) 184 | assert.NotNil(t, appContext.UserRepository) 185 | assert.NotNil(t, appContext.MedicineRepository) 186 | assert.NotNil(t, appContext.AuthUseCase) 187 | assert.NotNil(t, appContext.UserUseCase) 188 | assert.NotNil(t, appContext.MedicineUseCase) 189 | 190 | // Test that DB is nil in test context (as expected) 191 | assert.Nil(t, appContext.DB) 192 | } 193 | -------------------------------------------------------------------------------- /src/infrastructure/logger/logger.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "go.uber.org/zap" 11 | "go.uber.org/zap/zapcore" 12 | gormlogger "gorm.io/gorm/logger" 13 | ) 14 | 15 | type Logger struct { 16 | Log *zap.Logger 17 | } 18 | 19 | func NewLogger() (*Logger, error) { 20 | encoderConfig := zapcore.EncoderConfig{ 21 | TimeKey: "timestamp", 22 | LevelKey: "level", 23 | NameKey: "logger", 24 | CallerKey: "caller", 25 | MessageKey: "msg", 26 | StacktraceKey: "stacktrace", 27 | LineEnding: zapcore.DefaultLineEnding, 28 | EncodeLevel: zapcore.CapitalLevelEncoder, 29 | EncodeTime: zapcore.ISO8601TimeEncoder, 30 | EncodeDuration: zapcore.StringDurationEncoder, 31 | EncodeCaller: zapcore.FullCallerEncoder, 32 | EncodeName: zapcore.FullNameEncoder, 33 | } 34 | 35 | core := zapcore.NewCore( 36 | zapcore.NewJSONEncoder(encoderConfig), 37 | zapcore.AddSync(os.Stdout), 38 | zap.NewAtomicLevelAt(zap.InfoLevel), 39 | ) 40 | 41 | logger := zap.New(core) 42 | 43 | return &Logger{Log: logger}, nil 44 | } 45 | 46 | // NewDevelopmentLogger creates a logger for development with more debug information 47 | func NewDevelopmentLogger() (*Logger, error) { 48 | encoderConfig := zapcore.EncoderConfig{ 49 | TimeKey: "timestamp", 50 | LevelKey: "level", 51 | NameKey: "logger", 52 | CallerKey: "caller", 53 | MessageKey: "msg", 54 | StacktraceKey: "stacktrace", 55 | LineEnding: zapcore.DefaultLineEnding, 56 | EncodeLevel: zapcore.CapitalLevelEncoder, 57 | EncodeTime: zapcore.ISO8601TimeEncoder, 58 | EncodeDuration: zapcore.StringDurationEncoder, 59 | EncodeCaller: zapcore.FullCallerEncoder, 60 | EncodeName: zapcore.FullNameEncoder, 61 | } 62 | 63 | core := zapcore.NewCore( 64 | zapcore.NewJSONEncoder(encoderConfig), 65 | zapcore.AddSync(os.Stdout), 66 | zap.NewAtomicLevelAt(zap.DebugLevel), 67 | ) 68 | 69 | logger := zap.New(core, zap.AddStacktrace(zap.ErrorLevel)) 70 | 71 | return &Logger{Log: logger}, nil 72 | } 73 | 74 | func (l *Logger) Info(msg string, fields ...zap.Field) { 75 | l.Log.Info(msg, fields...) 76 | } 77 | 78 | func (l *Logger) Error(msg string, fields ...zap.Field) { 79 | l.Log.Error(msg, fields...) 80 | } 81 | func (l *Logger) Fatal(msg string, fields ...zap.Field) { 82 | l.Log.Fatal(msg, fields...) 83 | } 84 | 85 | func (l *Logger) Panic(msg string, fields ...zap.Field) { 86 | l.Log.Panic(msg, fields...) 87 | } 88 | func (l *Logger) Warn(msg string, fields ...zap.Field) { 89 | l.Log.Warn(msg, fields...) 90 | } 91 | 92 | func (l *Logger) Debug(msg string, fields ...zap.Field) { 93 | l.Log.Debug(msg, fields...) 94 | } 95 | 96 | // SetupGinWithZapLogger configures Gin to use the Zap logger 97 | func (l *Logger) SetupGinWithZapLogger() { 98 | // Configure Gin to use release mode by default 99 | gin.SetMode(gin.ReleaseMode) 100 | 101 | // Create a custom writer that uses Zap 102 | gin.DefaultWriter = &ZapWriter{logger: l.Log} 103 | gin.DefaultErrorWriter = &ZapErrorWriter{logger: l.Log} 104 | } 105 | 106 | // SetupGinWithZapLoggerInDevelopment configures Gin to use the Zap logger in development mode 107 | func (l *Logger) SetupGinWithZapLoggerInDevelopment() { 108 | // Configure Gin to use debug mode in development 109 | gin.SetMode(gin.DebugMode) 110 | 111 | // Create a custom writer that uses Zap 112 | gin.DefaultWriter = &ZapWriter{logger: l.Log} 113 | gin.DefaultErrorWriter = &ZapErrorWriter{logger: l.Log} 114 | } 115 | 116 | // SetupGinWithZapLoggerWithMode configures Gin to use the Zap logger with a specific mode 117 | func (l *Logger) SetupGinWithZapLoggerWithMode(mode string) { 118 | // Configure Gin to use the specified mode 119 | gin.SetMode(mode) 120 | 121 | // Create a custom writer that uses Zap 122 | gin.DefaultWriter = &ZapWriter{logger: l.Log} 123 | gin.DefaultErrorWriter = &ZapErrorWriter{logger: l.Log} 124 | } 125 | 126 | // ZapWriter implements io.Writer for use with Gin 127 | type ZapWriter struct { 128 | logger *zap.Logger 129 | } 130 | 131 | func (w *ZapWriter) Write(p []byte) (n int, err error) { 132 | w.logger.Info("Gin-log", zap.String("message", string(p))) 133 | return len(p), nil 134 | } 135 | 136 | // ZapErrorWriter implements io.Writer for Gin errors 137 | type ZapErrorWriter struct { 138 | logger *zap.Logger 139 | } 140 | 141 | func (w *ZapErrorWriter) Write(p []byte) (n int, err error) { 142 | w.logger.Error("Gin-error", zap.String("error", string(p))) 143 | return len(p), nil 144 | } 145 | 146 | func (l *Logger) GinZapLogger() gin.HandlerFunc { 147 | return func(c *gin.Context) { 148 | start := time.Now() 149 | c.Next() 150 | latency := time.Since(start) 151 | l.Log.Info("HTTP request", zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path), zap.Int("status", c.Writer.Status()), zap.Duration("latency", latency), zap.String("client_ip", c.ClientIP())) 152 | } 153 | } 154 | 155 | type GormZapLogger struct { 156 | zap *zap.SugaredLogger 157 | config gormlogger.Config 158 | } 159 | 160 | func NewGormLogger(base *zap.Logger) *GormZapLogger { 161 | sugar := base.Sugar() 162 | return &GormZapLogger{ 163 | zap: sugar, 164 | config: gormlogger.Config{ 165 | SlowThreshold: time.Second, // threshold to highlight slow queries 166 | LogLevel: gormlogger.Error, 167 | IgnoreRecordNotFoundError: true, // do not log "record not found" 168 | Colorful: false, 169 | }, 170 | } 171 | } 172 | 173 | func (l *GormZapLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { 174 | newCfg := l.config 175 | newCfg.LogLevel = level 176 | return &GormZapLogger{zap: l.zap, config: newCfg} 177 | } 178 | 179 | func (l *GormZapLogger) Info(ctx context.Context, msg string, data ...interface{}) { 180 | if l.config.LogLevel >= gormlogger.Info { 181 | l.zap.Infof(msg, data...) 182 | } 183 | } 184 | 185 | func (l *GormZapLogger) Warn(ctx context.Context, msg string, data ...interface{}) { 186 | if l.config.LogLevel >= gormlogger.Warn { 187 | l.zap.Warnf(msg, data...) 188 | } 189 | } 190 | 191 | func (l *GormZapLogger) Error(ctx context.Context, msg string, data ...interface{}) { 192 | if l.config.LogLevel >= gormlogger.Error && 193 | (!l.config.IgnoreRecordNotFoundError || msg != gormlogger.ErrRecordNotFound.Error()) { 194 | l.zap.Errorf(msg, data...) 195 | } 196 | } 197 | 198 | func (l *GormZapLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { 199 | elapsed := time.Since(begin) 200 | 201 | if err != nil { 202 | if l.config.IgnoreRecordNotFoundError && errors.Is(err, gormlogger.ErrRecordNotFound) { 203 | return 204 | } 205 | if l.config.LogLevel >= gormlogger.Error { 206 | sql, rows := fc() 207 | l.zap.Errorf("Error: %v | %.3fms | rows:%d | %s", err, float64(elapsed.Nanoseconds())/1e6, rows, sql) 208 | } 209 | return 210 | } 211 | 212 | if elapsed > l.config.SlowThreshold && l.config.LogLevel >= gormlogger.Warn { 213 | sql, rows := fc() 214 | l.zap.Warnf("SLOW ≥ %s | %.3fms | rows:%d | %s", l.config.SlowThreshold, float64(elapsed.Nanoseconds())/1e6, rows, sql) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/infrastructure/repository/psql/medicine/medicine.go: -------------------------------------------------------------------------------- 1 | package medicine 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/gbrayhan/microservices-go/src/domain" 8 | domainErrors "github.com/gbrayhan/microservices-go/src/domain/errors" 9 | domainMedicine "github.com/gbrayhan/microservices-go/src/domain/medicine" 10 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 11 | "go.uber.org/zap" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | // MedicineRepositoryInterface defines the interface for medicine repository operations 16 | type MedicineRepositoryInterface interface { 17 | GetAll() (*[]domainMedicine.Medicine, error) 18 | GetByID(id int) (*domainMedicine.Medicine, error) 19 | Create(medicine *domainMedicine.Medicine) (*domainMedicine.Medicine, error) 20 | Delete(id int) error 21 | Update(id int, medicineMap map[string]any) (*domainMedicine.Medicine, error) 22 | SearchPaginated(filters domain.DataFilters) (*domainMedicine.SearchResultMedicine, error) 23 | SearchByProperty(property string, searchText string) (*[]string, error) 24 | } 25 | 26 | // Structures 27 | type Medicine struct { 28 | ID int `gorm:"primaryKey"` 29 | Name string `gorm:"unique"` 30 | Description string 31 | EANCode string `gorm:"unique"` 32 | Laboratory string 33 | CreatedAt time.Time `gorm:"autoCreateTime:milli"` 34 | UpdatedAt time.Time `gorm:"autoUpdateTime:milli"` 35 | } 36 | 37 | type PaginationResultMedicine struct { 38 | Data *[]domainMedicine.Medicine 39 | Total int64 40 | Limit int64 41 | Current int64 42 | NextCursor uint 43 | PrevCursor uint 44 | NumPages int64 45 | } 46 | 47 | func (*Medicine) TableName() string { 48 | return "medicines" 49 | } 50 | 51 | var ColumnsMedicineMapping = map[string]string{ 52 | "id": "id", 53 | "name": "name", 54 | "description": "description", 55 | "eanCode": "ean_code", 56 | "laboratory": "laboratory", 57 | "createdAt": "created_at", 58 | "updatedAt": "updated_at", 59 | } 60 | 61 | type Repository struct { 62 | DB *gorm.DB 63 | Logger *logger.Logger 64 | } 65 | 66 | func NewMedicineRepository(DB *gorm.DB, loggerInstance *logger.Logger) MedicineRepositoryInterface { 67 | return &Repository{ 68 | DB: DB, 69 | Logger: loggerInstance, 70 | } 71 | } 72 | 73 | func (r *Repository) Create(newMedicine *domainMedicine.Medicine) (*domainMedicine.Medicine, error) { 74 | medicine := &Medicine{ 75 | Name: newMedicine.Name, 76 | Description: newMedicine.Description, 77 | EANCode: newMedicine.EanCode, 78 | Laboratory: newMedicine.Laboratory, 79 | } 80 | 81 | tx := r.DB.Create(medicine) 82 | if tx.Error != nil { 83 | r.Logger.Error("Error creating medicine", zap.Error(tx.Error), zap.String("name", newMedicine.Name)) 84 | byteErr, _ := json.Marshal(tx.Error) 85 | var newError domainErrors.GormErr 86 | err := json.Unmarshal(byteErr, &newError) 87 | if err != nil { 88 | return nil, err 89 | } 90 | switch newError.Number { 91 | case 1062: 92 | return nil, domainErrors.NewAppErrorWithType(domainErrors.ResourceAlreadyExists) 93 | default: 94 | return nil, domainErrors.NewAppErrorWithType(domainErrors.UnknownError) 95 | } 96 | } 97 | r.Logger.Info("Successfully created medicine", zap.String("name", newMedicine.Name), zap.Int("id", medicine.ID)) 98 | return medicine.toDomainMapper(), nil 99 | } 100 | 101 | func (r *Repository) GetByID(id int) (*domainMedicine.Medicine, error) { 102 | var medicine Medicine 103 | err := r.DB.Where("id = ?", id).First(&medicine).Error 104 | if err != nil { 105 | if err == gorm.ErrRecordNotFound { 106 | r.Logger.Warn("Medicine not found", zap.Int("id", id)) 107 | return nil, domainErrors.NewAppErrorWithType(domainErrors.NotFound) 108 | } 109 | r.Logger.Error("Error getting medicine by ID", zap.Error(err), zap.Int("id", id)) 110 | return nil, domainErrors.NewAppErrorWithType(domainErrors.UnknownError) 111 | } 112 | r.Logger.Info("Successfully retrieved medicine by ID", zap.Int("id", id)) 113 | return medicine.toDomainMapper(), nil 114 | } 115 | 116 | func (r *Repository) Update(id int, medicineMap map[string]any) (*domainMedicine.Medicine, error) { 117 | var med Medicine 118 | med.ID = id 119 | err := r.DB.Model(&med). 120 | Select("name", "description", "ean_code", "laboratory"). 121 | Updates(medicineMap).Error 122 | if err != nil { 123 | r.Logger.Error("Error updating medicine", zap.Error(err), zap.Int("id", id)) 124 | byteErr, _ := json.Marshal(err) 125 | var newError domainErrors.GormErr 126 | errUnmarshal := json.Unmarshal(byteErr, &newError) 127 | if errUnmarshal != nil { 128 | return nil, errUnmarshal 129 | } 130 | switch newError.Number { 131 | case 1062: 132 | return nil, domainErrors.NewAppErrorWithType(domainErrors.ResourceAlreadyExists) 133 | default: 134 | return nil, domainErrors.NewAppErrorWithType(domainErrors.UnknownError) 135 | } 136 | } 137 | err = r.DB.Where("id = ?", id).First(&med).Error 138 | if err != nil { 139 | r.Logger.Error("Error retrieving updated medicine", zap.Error(err), zap.Int("id", id)) 140 | return nil, err 141 | } 142 | r.Logger.Info("Successfully updated medicine", zap.Int("id", id)) 143 | return med.toDomainMapper(), nil 144 | } 145 | 146 | func (r *Repository) Delete(id int) error { 147 | tx := r.DB.Delete(&Medicine{}, id) 148 | if tx.Error != nil { 149 | r.Logger.Error("Error deleting medicine", zap.Error(tx.Error), zap.Int("id", id)) 150 | return domainErrors.NewAppErrorWithType(domainErrors.UnknownError) 151 | } 152 | if tx.RowsAffected == 0 { 153 | r.Logger.Warn("Medicine not found for deletion", zap.Int("id", id)) 154 | return domainErrors.NewAppErrorWithType(domainErrors.NotFound) 155 | } 156 | r.Logger.Info("Successfully deleted medicine", zap.Int("id", id)) 157 | return nil 158 | } 159 | 160 | func (r *Repository) GetAll() (*[]domainMedicine.Medicine, error) { 161 | var medicines []Medicine 162 | if err := r.DB.Find(&medicines).Error; err != nil { 163 | r.Logger.Error("Error getting all medicines", zap.Error(err)) 164 | return nil, domainErrors.NewAppErrorWithType(domainErrors.UnknownError) 165 | } 166 | r.Logger.Info("Successfully retrieved all medicines", zap.Int("count", len(medicines))) 167 | return arrayToDomainMapper(&medicines), nil 168 | } 169 | 170 | // Mappers 171 | func (m *Medicine) toDomainMapper() *domainMedicine.Medicine { 172 | return &domainMedicine.Medicine{ 173 | ID: m.ID, 174 | Name: m.Name, 175 | Description: m.Description, 176 | EanCode: m.EANCode, 177 | Laboratory: m.Laboratory, 178 | CreatedAt: m.CreatedAt, 179 | UpdatedAt: m.UpdatedAt, 180 | } 181 | } 182 | 183 | func arrayToDomainMapper(medicines *[]Medicine) *[]domainMedicine.Medicine { 184 | medicinesDomain := make([]domainMedicine.Medicine, len(*medicines)) 185 | for i, medicine := range *medicines { 186 | medicinesDomain[i] = *medicine.toDomainMapper() 187 | } 188 | return &medicinesDomain 189 | } 190 | 191 | // IsZeroValue checks if a value is the zero value of its type 192 | 193 | func (r *Repository) SearchPaginated(filters domain.DataFilters) (*domainMedicine.SearchResultMedicine, error) { 194 | query := r.DB.Model(&Medicine{}) 195 | 196 | // Apply like filters 197 | for field, values := range filters.LikeFilters { 198 | if len(values) > 0 { 199 | for _, value := range values { 200 | if value != "" { 201 | column := ColumnsMedicineMapping[field] 202 | if column != "" { 203 | query = query.Where(column+" ILIKE ?", "%"+value+"%") 204 | } 205 | } 206 | } 207 | } 208 | } 209 | 210 | // Apply exact matches 211 | for field, values := range filters.Matches { 212 | if len(values) > 0 { 213 | column := ColumnsMedicineMapping[field] 214 | if column != "" { 215 | query = query.Where(column+" IN ?", values) 216 | } 217 | } 218 | } 219 | 220 | // Apply date range filters 221 | for _, dateFilter := range filters.DateRangeFilters { 222 | column := ColumnsMedicineMapping[dateFilter.Field] 223 | if column != "" { 224 | if dateFilter.Start != nil { 225 | query = query.Where(column+" >= ?", dateFilter.Start) 226 | } 227 | if dateFilter.End != nil { 228 | query = query.Where(column+" <= ?", dateFilter.End) 229 | } 230 | } 231 | } 232 | 233 | // Apply sorting 234 | if len(filters.SortBy) > 0 && filters.SortDirection.IsValid() { 235 | for _, sortField := range filters.SortBy { 236 | column := ColumnsMedicineMapping[sortField] 237 | if column != "" { 238 | query = query.Order(column + " " + string(filters.SortDirection)) 239 | } 240 | } 241 | } 242 | 243 | // Count total records 244 | var total int64 245 | clonedQuery := query 246 | clonedQuery.Count(&total) 247 | 248 | // Apply pagination 249 | if filters.Page < 1 { 250 | filters.Page = 1 251 | } 252 | if filters.PageSize < 1 { 253 | filters.PageSize = 10 254 | } 255 | offset := (filters.Page - 1) * filters.PageSize 256 | 257 | var medicines []Medicine 258 | if err := query.Offset(offset).Limit(filters.PageSize).Find(&medicines).Error; err != nil { 259 | r.Logger.Error("Error searching medicines", zap.Error(err)) 260 | return nil, domainErrors.NewAppErrorWithType(domainErrors.UnknownError) 261 | } 262 | 263 | totalPages := int((total + int64(filters.PageSize) - 1) / int64(filters.PageSize)) 264 | 265 | result := &domainMedicine.SearchResultMedicine{ 266 | Data: arrayToDomainMapper(&medicines), 267 | Total: total, 268 | Page: filters.Page, 269 | PageSize: filters.PageSize, 270 | TotalPages: totalPages, 271 | } 272 | 273 | r.Logger.Info("Successfully searched medicines", 274 | zap.Int64("total", total), 275 | zap.Int("page", filters.Page), 276 | zap.Int("pageSize", filters.PageSize)) 277 | 278 | return result, nil 279 | } 280 | 281 | func (r *Repository) SearchByProperty(property string, searchText string) (*[]string, error) { 282 | column := ColumnsMedicineMapping[property] 283 | if column == "" { 284 | r.Logger.Warn("Invalid property for search", zap.String("property", property)) 285 | return nil, domainErrors.NewAppErrorWithType(domainErrors.ValidationError) 286 | } 287 | 288 | var coincidences []string 289 | if err := r.DB.Model(&Medicine{}). 290 | Distinct(column). 291 | Where(column+" ILIKE ?", "%"+searchText+"%"). 292 | Limit(20). 293 | Pluck(column, &coincidences).Error; err != nil { 294 | r.Logger.Error("Error searching by property", zap.Error(err), zap.String("property", property)) 295 | return nil, domainErrors.NewAppErrorWithType(domainErrors.UnknownError) 296 | } 297 | 298 | r.Logger.Info("Successfully searched by property", 299 | zap.String("property", property), 300 | zap.Int("results", len(coincidences))) 301 | 302 | return &coincidences, nil 303 | } 304 | -------------------------------------------------------------------------------- /src/infrastructure/repository/psql/medicine/medicine_test.go: -------------------------------------------------------------------------------- 1 | package medicine 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | "time" 7 | 8 | "github.com/DATA-DOG/go-sqlmock" 9 | medicineDomain "github.com/gbrayhan/microservices-go/src/domain/medicine" 10 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "gorm.io/driver/postgres" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { 18 | db, mock, err := sqlmock.New() 19 | require.NoError(t, err) 20 | gormDB, err := gorm.Open(postgres.New(postgres.Config{ 21 | Conn: db, 22 | }), &gorm.Config{}) 23 | require.NoError(t, err) 24 | cleanup := func() { db.Close() } 25 | return gormDB, mock, cleanup 26 | } 27 | 28 | func setupLogger(t *testing.T) *logger.Logger { 29 | loggerInstance, err := logger.NewLogger() 30 | require.NoError(t, err) 31 | return loggerInstance 32 | } 33 | 34 | func TestTableName(t *testing.T) { 35 | medicine := &Medicine{} 36 | assert.Equal(t, "medicines", medicine.TableName()) 37 | } 38 | 39 | func TestNewMedicineRepository(t *testing.T) { 40 | db, _, cleanup := setupMockDB(t) 41 | defer cleanup() 42 | logger := setupLogger(t) 43 | repo := NewMedicineRepository(db, logger) 44 | assert.NotNil(t, repo) 45 | } 46 | 47 | func TestToDomainMapper(t *testing.T) { 48 | now := time.Now() 49 | medicine := &Medicine{ 50 | ID: 1, 51 | Name: "Test Medicine", 52 | Description: "Test Description", 53 | EANCode: "1234567890123", 54 | Laboratory: "Test Lab", 55 | CreatedAt: now, 56 | UpdatedAt: now, 57 | } 58 | 59 | domainMedicine := medicine.toDomainMapper() 60 | 61 | assert.Equal(t, medicine.ID, domainMedicine.ID) 62 | assert.Equal(t, medicine.Name, domainMedicine.Name) 63 | assert.Equal(t, medicine.Description, domainMedicine.Description) 64 | assert.Equal(t, medicine.EANCode, domainMedicine.EanCode) 65 | assert.Equal(t, medicine.Laboratory, domainMedicine.Laboratory) 66 | assert.Equal(t, medicine.CreatedAt, domainMedicine.CreatedAt) 67 | assert.Equal(t, medicine.UpdatedAt, domainMedicine.UpdatedAt) 68 | } 69 | 70 | func TestArrayToDomainMapper(t *testing.T) { 71 | now := time.Now() 72 | medicines := []Medicine{ 73 | { 74 | ID: 1, 75 | Name: "Medicine 1", 76 | Description: "Description 1", 77 | EANCode: "1234567890123", 78 | Laboratory: "Lab 1", 79 | CreatedAt: now, 80 | UpdatedAt: now, 81 | }, 82 | { 83 | ID: 2, 84 | Name: "Medicine 2", 85 | Description: "Description 2", 86 | EANCode: "1234567890124", 87 | Laboratory: "Lab 2", 88 | CreatedAt: now, 89 | UpdatedAt: now, 90 | }, 91 | } 92 | 93 | domainMedicines := arrayToDomainMapper(&medicines) 94 | 95 | assert.Len(t, *domainMedicines, 2) 96 | assert.Equal(t, medicines[0].ID, (*domainMedicines)[0].ID) 97 | assert.Equal(t, medicines[1].ID, (*domainMedicines)[1].ID) 98 | } 99 | 100 | // Integration-style tests using in-memory SQLite database 101 | func TestRepository_GetAll(t *testing.T) { 102 | db, mock, cleanup := setupMockDB(t) 103 | defer cleanup() 104 | logger := setupLogger(t) 105 | repo := NewMedicineRepository(db, logger) 106 | rows := sqlmock.NewRows([]string{"id", "name", "description", "ean_code", "laboratory"}). 107 | AddRow(1, "Medicine 1", "Description 1", "1234567890123", "Lab 1"). 108 | AddRow(2, "Medicine 2", "Description 2", "1234567890124", "Lab 2") 109 | mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "medicines"`)).WillReturnRows(rows) 110 | medicines, err := repo.GetAll() 111 | assert.NoError(t, err) 112 | assert.NotNil(t, medicines) 113 | assert.Len(t, *medicines, 2) 114 | } 115 | 116 | func TestRepository_GetByID(t *testing.T) { 117 | db, mock, cleanup := setupMockDB(t) 118 | defer cleanup() 119 | logger := setupLogger(t) 120 | repo := NewMedicineRepository(db, logger) 121 | rows := sqlmock.NewRows([]string{"id", "name", "description", "ean_code", "laboratory"}). 122 | AddRow(1, "Medicine 1", "Description 1", "1234567890123", "Lab 1") 123 | mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "medicines" WHERE id = $1 ORDER BY "medicines"."id" LIMIT $2`)). 124 | WithArgs(1, 1).WillReturnRows(rows) 125 | medicine, err := repo.GetByID(1) 126 | assert.NoError(t, err) 127 | assert.NotNil(t, medicine) 128 | assert.Equal(t, "Medicine 1", medicine.Name) 129 | } 130 | 131 | func TestRepository_Create(t *testing.T) { 132 | db, mock, cleanup := setupMockDB(t) 133 | defer cleanup() 134 | logger := setupLogger(t) 135 | repo := NewMedicineRepository(db, logger) 136 | domainM := &medicineDomain.Medicine{ 137 | Name: "New Medicine", 138 | Description: "New Description", 139 | EanCode: "1234567890125", 140 | Laboratory: "New Lab", 141 | } 142 | mock.ExpectBegin() 143 | mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "medicines"`)). 144 | WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) 145 | mock.ExpectCommit() 146 | medicine, err := repo.Create(domainM) 147 | assert.NoError(t, err) 148 | assert.NotNil(t, medicine) 149 | assert.Equal(t, "New Medicine", medicine.Name) 150 | } 151 | 152 | func TestRepository_Update(t *testing.T) { 153 | db, mock, cleanup := setupMockDB(t) 154 | defer cleanup() 155 | logger := setupLogger(t) 156 | repo := NewMedicineRepository(db, logger) 157 | mock.ExpectBegin() 158 | mock.ExpectExec(regexp.QuoteMeta(`UPDATE "medicines" SET`)). 159 | WillReturnResult(sqlmock.NewResult(0, 1)) 160 | mock.ExpectCommit() 161 | rows := sqlmock.NewRows([]string{"id", "name", "description", "ean_code", "laboratory"}). 162 | AddRow(1, "Updated Medicine", "Updated Description", "1234567890123", "Updated Lab") 163 | mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "medicines" WHERE id = $1 AND "medicines"."id" = $2 ORDER BY "medicines"."id" LIMIT $3`)). 164 | WithArgs(1, 1, 1).WillReturnRows(rows) 165 | medicine, err := repo.Update(1, map[string]any{"name": "Updated Medicine"}) 166 | assert.NoError(t, err) 167 | assert.NotNil(t, medicine) 168 | assert.Equal(t, "Updated Medicine", medicine.Name) 169 | } 170 | 171 | func TestRepository_Delete(t *testing.T) { 172 | db, mock, cleanup := setupMockDB(t) 173 | defer cleanup() 174 | logger := setupLogger(t) 175 | repo := NewMedicineRepository(db, logger) 176 | mock.ExpectBegin() 177 | mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM "medicines" WHERE "medicines"."id" = $1`)). 178 | WithArgs(1).WillReturnResult(sqlmock.NewResult(0, 1)) 179 | mock.ExpectCommit() 180 | err := repo.Delete(1) 181 | assert.NoError(t, err) 182 | } 183 | 184 | func TestColumnsMedicineMapping(t *testing.T) { 185 | assert.Equal(t, "id", ColumnsMedicineMapping["id"]) 186 | assert.Equal(t, "name", ColumnsMedicineMapping["name"]) 187 | assert.Equal(t, "description", ColumnsMedicineMapping["description"]) 188 | assert.Equal(t, "ean_code", ColumnsMedicineMapping["eanCode"]) 189 | assert.Equal(t, "laboratory", ColumnsMedicineMapping["laboratory"]) 190 | assert.Equal(t, "created_at", ColumnsMedicineMapping["createdAt"]) 191 | assert.Equal(t, "updated_at", ColumnsMedicineMapping["updatedAt"]) 192 | } 193 | -------------------------------------------------------------------------------- /src/infrastructure/repository/psql/psql_repository.go: -------------------------------------------------------------------------------- 1 | package psql 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 9 | "github.com/gbrayhan/microservices-go/src/infrastructure/repository/psql/medicine" 10 | "github.com/gbrayhan/microservices-go/src/infrastructure/repository/psql/user" 11 | "go.uber.org/zap" 12 | "golang.org/x/crypto/bcrypt" 13 | "gorm.io/driver/postgres" 14 | "gorm.io/gorm" 15 | gormlogger "gorm.io/gorm/logger" 16 | ) 17 | 18 | // DatabaseConfig holds database-related configuration 19 | type DatabaseConfig struct { 20 | Host string 21 | Port string 22 | User string 23 | Password string 24 | DBName string 25 | SSLMode string 26 | } 27 | 28 | // loadDatabaseConfig loads database configuration from environment variables 29 | // Returns error if any required environment variable is missing 30 | func loadDatabaseConfig() (DatabaseConfig, error) { 31 | host := os.Getenv("DB_HOST") 32 | port := os.Getenv("DB_PORT") 33 | user := os.Getenv("DB_USER") 34 | password := os.Getenv("DB_PASSWORD") 35 | dbName := os.Getenv("DB_NAME") 36 | sslMode := os.Getenv("DB_SSLMODE") 37 | 38 | // Check for missing required environment variables 39 | var missingVars []string 40 | if host == "" { 41 | missingVars = append(missingVars, "DB_HOST") 42 | } 43 | if port == "" { 44 | missingVars = append(missingVars, "DB_PORT") 45 | } 46 | if user == "" { 47 | missingVars = append(missingVars, "DB_USER") 48 | } 49 | if password == "" { 50 | missingVars = append(missingVars, "DB_PASSWORD") 51 | } 52 | if dbName == "" { 53 | missingVars = append(missingVars, "DB_NAME") 54 | } 55 | if sslMode == "" { 56 | missingVars = append(missingVars, "DB_SSLMODE") 57 | } 58 | 59 | if len(missingVars) > 0 { 60 | return DatabaseConfig{}, fmt.Errorf("missing required database environment variables: %s", strings.Join(missingVars, ", ")) 61 | } 62 | 63 | return DatabaseConfig{ 64 | Host: host, 65 | Port: port, 66 | User: user, 67 | Password: password, 68 | DBName: dbName, 69 | SSLMode: sslMode, 70 | }, nil 71 | } 72 | 73 | type PSQLRepository struct { 74 | DB *gorm.DB 75 | Logger *logger.Logger 76 | Auth AuthService 77 | } 78 | 79 | type AuthService interface { 80 | HashPassword(password string) (string, error) 81 | } 82 | 83 | func NewRepository(db *gorm.DB, loggerInstance *logger.Logger) *PSQLRepository { 84 | return &PSQLRepository{ 85 | DB: db, 86 | Logger: loggerInstance, 87 | } 88 | } 89 | 90 | func (r *PSQLRepository) SetLogger(loggerInstance *logger.Logger) { 91 | r.Logger = loggerInstance 92 | } 93 | 94 | func (r *PSQLRepository) SetAuthService(auth AuthService) { 95 | r.Auth = auth 96 | } 97 | 98 | func (r *PSQLRepository) LoadDBConfig() (DatabaseConfig, error) { 99 | return loadDatabaseConfig() 100 | } 101 | 102 | func (c DatabaseConfig) GetDSN() string { 103 | return "host=" + c.Host + 104 | " port=" + c.Port + 105 | " user=" + c.User + 106 | " password=" + c.Password + 107 | " dbname=" + c.DBName + 108 | " sslmode=" + c.SSLMode + 109 | " TimeZone=America/Mexico_City" 110 | } 111 | 112 | func (r *PSQLRepository) InitDatabase() error { 113 | cfg, err := loadDatabaseConfig() 114 | if err != nil { 115 | r.Logger.Error("Failed to load database configuration", zap.Error(err)) 116 | return fmt.Errorf("failed to load database configuration: %w", err) 117 | } 118 | 119 | // Create GORM logger with zap 120 | gormZap := logger.NewGormLogger(r.Logger.Log). 121 | LogMode(gormlogger.Warn) // Silent / Error / Warn / Info 122 | 123 | r.DB, err = gorm.Open(postgres.Open(cfg.GetDSN()), &gorm.Config{ 124 | Logger: gormZap, 125 | }) 126 | if err != nil { 127 | r.Logger.Error("Error connecting to the database", zap.Error(err)) 128 | return err 129 | } 130 | 131 | err = r.MigrateEntitiesGORM() 132 | if err != nil { 133 | r.Logger.Error("Error migrating the database", zap.Error(err)) 134 | return err 135 | } 136 | 137 | err = r.SeedInitialUser() 138 | if err != nil { 139 | r.Logger.Error("Error seeding initial user", zap.Error(err)) 140 | return err 141 | } 142 | 143 | r.Logger.Info("Database connection and migrations successful") 144 | return nil 145 | } 146 | 147 | func (r *PSQLRepository) MigrateEntitiesGORM() error { 148 | // Import the models to register them with GORM 149 | userModel := &user.User{} 150 | medicineModel := &medicine.Medicine{} 151 | 152 | // Auto migrate the models to create/update tables 153 | err := r.DB.AutoMigrate(userModel, medicineModel) 154 | if err != nil { 155 | r.Logger.Error("Error migrating database entities", zap.Error(err)) 156 | return err 157 | } 158 | 159 | r.Logger.Info("Database entities migration completed successfully") 160 | return nil 161 | } 162 | 163 | func (r *PSQLRepository) SeedInitialUser() error { 164 | email := os.Getenv("START_USER_EMAIL") 165 | pw := os.Getenv("START_USER_PW") 166 | if email == "" || pw == "" { 167 | r.Logger.Info("Initial user seed skipped: START_USER_EMAIL or START_USER_PW not set") 168 | return nil 169 | } 170 | 171 | // Check if user already exists 172 | var existingUser user.User 173 | err := r.DB.Where("email = ?", email).First(&existingUser).Error 174 | if err == nil { 175 | r.Logger.Info("Initial user already exists, skipping seed", zap.String("email", email)) 176 | return nil 177 | } 178 | 179 | // Create initial user 180 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) 181 | if err != nil { 182 | r.Logger.Error("Error hashing password for initial user", zap.Error(err)) 183 | return err 184 | } 185 | 186 | newUser := user.User{ 187 | Email: email, 188 | HashPassword: string(hashedPassword), 189 | } 190 | 191 | err = r.DB.Create(&newUser).Error 192 | if err != nil { 193 | r.Logger.Error("Error creating initial user", zap.Error(err)) 194 | return err 195 | } 196 | 197 | r.Logger.Info("Initial user created successfully", zap.String("email", email)) 198 | return nil 199 | } 200 | 201 | // InitPSQLDB initializes the database connection with logger 202 | func InitPSQLDB(loggerInstance *logger.Logger) (*gorm.DB, error) { 203 | repo := &PSQLRepository{ 204 | Logger: loggerInstance, 205 | } 206 | 207 | err := repo.InitDatabase() 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | return repo.DB, nil 213 | } 214 | -------------------------------------------------------------------------------- /src/infrastructure/repository/psql/user/user_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | "time" 7 | 8 | "github.com/DATA-DOG/go-sqlmock" 9 | domainUser "github.com/gbrayhan/microservices-go/src/domain/user" 10 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "gorm.io/driver/postgres" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { 18 | db, mock, err := sqlmock.New() 19 | require.NoError(t, err) 20 | gormDB, err := gorm.Open(postgres.New(postgres.Config{ 21 | Conn: db, 22 | }), &gorm.Config{}) 23 | require.NoError(t, err) 24 | cleanup := func() { db.Close() } 25 | return gormDB, mock, cleanup 26 | } 27 | 28 | func setupLogger(t *testing.T) *logger.Logger { 29 | loggerInstance, err := logger.NewLogger() 30 | if err != nil { 31 | t.Fatalf("Failed to create logger: %v", err) 32 | } 33 | return loggerInstance 34 | } 35 | 36 | func TestTableName(t *testing.T) { 37 | u := &User{} 38 | assert.Equal(t, "users", u.TableName()) 39 | } 40 | 41 | func TestNewUserRepository(t *testing.T) { 42 | db, _, cleanup := setupMockDB(t) 43 | defer cleanup() 44 | logger := setupLogger(t) 45 | repo := NewUserRepository(db, logger) 46 | assert.NotNil(t, repo) 47 | } 48 | 49 | func TestToDomainMapper(t *testing.T) { 50 | u := &User{ 51 | ID: 1, 52 | UserName: "testuser", 53 | Email: "test@example.com", 54 | FirstName: "Test", 55 | LastName: "User", 56 | Status: true, 57 | CreatedAt: time.Now(), 58 | UpdatedAt: time.Now(), 59 | } 60 | d := u.toDomainMapper() 61 | assert.Equal(t, u.UserName, d.UserName) 62 | } 63 | 64 | func TestFromDomainMapper(t *testing.T) { 65 | d := &domainUser.User{ 66 | ID: 1, 67 | UserName: "testuser", 68 | Email: "test@example.com", 69 | FirstName: "Test", 70 | LastName: "User", 71 | Status: true, 72 | CreatedAt: time.Now(), 73 | UpdatedAt: time.Now(), 74 | } 75 | u := fromDomainMapper(d) 76 | assert.Equal(t, d.UserName, u.UserName) 77 | } 78 | 79 | func TestArrayToDomainMapper(t *testing.T) { 80 | arr := &[]User{{ID: 1, UserName: "A"}, {ID: 2, UserName: "B"}} 81 | d := arrayToDomainMapper(arr) 82 | assert.Len(t, *d, 2) 83 | assert.Equal(t, "A", (*d)[0].UserName) 84 | } 85 | 86 | func TestRepository_GetAll(t *testing.T) { 87 | db, mock, cleanup := setupMockDB(t) 88 | defer cleanup() 89 | logger := setupLogger(t) 90 | repo := NewUserRepository(db, logger) 91 | rows := sqlmock.NewRows([]string{"id", "user_name", "email", "first_name", "last_name", "status", "hash_password"}). 92 | AddRow(1, "user1", "a@a.com", "A", "B", true, "hash1"). 93 | AddRow(2, "user2", "b@b.com", "C", "D", false, "hash2") 94 | mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "users"`)).WillReturnRows(rows) 95 | users, err := repo.GetAll() 96 | assert.NoError(t, err) 97 | assert.NotNil(t, users) 98 | assert.Len(t, *users, 2) 99 | } 100 | 101 | func TestRepository_GetByID(t *testing.T) { 102 | db, mock, cleanup := setupMockDB(t) 103 | defer cleanup() 104 | logger := setupLogger(t) 105 | repo := NewUserRepository(db, logger) 106 | rows := sqlmock.NewRows([]string{"id", "user_name", "email", "first_name", "last_name", "status", "hash_password"}). 107 | AddRow(1, "user1", "a@a.com", "A", "B", true, "hash1") 108 | mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "users" WHERE id = $1 ORDER BY "users"."id" LIMIT $2`)). 109 | WithArgs(1, 1).WillReturnRows(rows) 110 | user, err := repo.GetByID(1) 111 | assert.NoError(t, err) 112 | assert.NotNil(t, user) 113 | assert.Equal(t, "user1", user.UserName) 114 | // Not found 115 | mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "users" WHERE id = $1 ORDER BY "users"."id" LIMIT $2`)). 116 | WithArgs(2, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "user_name", "email", "first_name", "last_name", "status", "hash_password"})) 117 | user, err = repo.GetByID(2) 118 | assert.Error(t, err) 119 | assert.NotNil(t, user) 120 | assert.Equal(t, 0, user.ID) // Should be zero value 121 | } 122 | 123 | func TestRepository_Create(t *testing.T) { 124 | db, mock, cleanup := setupMockDB(t) 125 | defer cleanup() 126 | logger := setupLogger(t) 127 | repo := NewUserRepository(db, logger) 128 | domainU := &domainUser.User{ 129 | UserName: "user1", 130 | Email: "a@a.com", 131 | FirstName: "A", 132 | LastName: "B", 133 | Status: true, 134 | HashPassword: "hash1", 135 | } 136 | mock.ExpectBegin() 137 | mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "users"`)). 138 | WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) 139 | mock.ExpectCommit() 140 | user, err := repo.Create(domainU) 141 | assert.NoError(t, err) 142 | assert.NotNil(t, user) 143 | assert.Equal(t, "user1", user.UserName) 144 | } 145 | 146 | func TestRepository_Delete(t *testing.T) { 147 | db, mock, cleanup := setupMockDB(t) 148 | defer cleanup() 149 | logger := setupLogger(t) 150 | repo := NewUserRepository(db, logger) 151 | mock.ExpectBegin() 152 | mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM "users" WHERE "users"."id" = $1`)). 153 | WithArgs(1).WillReturnResult(sqlmock.NewResult(0, 1)) 154 | mock.ExpectCommit() 155 | err := repo.Delete(1) 156 | assert.NoError(t, err) 157 | mock.ExpectBegin() 158 | mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM "users" WHERE "users"."id" = $1`)). 159 | WithArgs(2).WillReturnResult(sqlmock.NewResult(0, 0)) 160 | mock.ExpectCommit() 161 | err = repo.Delete(2) 162 | assert.Error(t, err) 163 | } 164 | 165 | func TestRepository_GetByEmail(t *testing.T) { 166 | db, mock, cleanup := setupMockDB(t) 167 | defer cleanup() 168 | logger := setupLogger(t) 169 | repo := NewUserRepository(db, logger) 170 | 171 | email := "test@example.com" 172 | rows := sqlmock.NewRows([]string{"id", "user_name", "email", "first_name", "last_name", "status", "hash_password"}). 173 | AddRow(1, "user1", email, "A", "B", true, "hash1") 174 | mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "users" WHERE email = $1 ORDER BY "users"."id" LIMIT $2`)). 175 | WithArgs(email, 1).WillReturnRows(rows) 176 | user, err := repo.GetByEmail(email) 177 | assert.NoError(t, err) 178 | assert.NotNil(t, user) 179 | assert.Equal(t, email, user.Email) 180 | 181 | // Not found 182 | emailNotFound := "notfound@example.com" 183 | mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "users" WHERE email = $1 ORDER BY "users"."id" LIMIT $2`)). 184 | WithArgs(emailNotFound, 1).WillReturnRows(sqlmock.NewRows([]string{"id", "user_name", "email", "first_name", "last_name", "status", "hash_password"})) 185 | user, err = repo.GetByEmail(emailNotFound) 186 | assert.Error(t, err) 187 | assert.NotNil(t, user) 188 | assert.Equal(t, 0, user.ID) // Should be zero value 189 | } 190 | 191 | // The following tests need refactoring to use sqlmock or should be moved to integration: 192 | // TestRepository_GetOneByMap 193 | // TestRepository_Update 194 | // TestRepository_Create_DuplicateEmail 195 | // TestRepository_ErrorCases 196 | // TestRepository_GetOneByMap_WithFilters 197 | // TestRepository_Update_WithMultipleFields 198 | // 199 | // If you want me to refactor these as well, let me know and I'll do them one by one. 200 | -------------------------------------------------------------------------------- /src/infrastructure/rest/controllers/BindTools.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func BindJSON(c *gin.Context, request any) error { 12 | buf := make([]byte, 5120) 13 | num, _ := c.Request.Body.Read(buf) 14 | reqBody := string(buf[0:num]) 15 | c.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(reqBody))) 16 | err := c.ShouldBindJSON(request) 17 | c.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(reqBody))) 18 | return err 19 | } 20 | 21 | func BindJSONMap(c *gin.Context, request *map[string]any) error { 22 | buf := make([]byte, 5120) 23 | num, _ := c.Request.Body.Read(buf) 24 | reqBody := buf[0:num] 25 | c.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody)) 26 | err := json.Unmarshal(reqBody, &request) 27 | c.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody)) 28 | return err 29 | } 30 | 31 | type MessageResponse struct { 32 | Message string `json:"message"` 33 | } 34 | 35 | type SortByDataRequest struct { 36 | Field string `json:"field"` 37 | Direction string `json:"direction"` 38 | } 39 | 40 | type FieldDateRangeDataRequest struct { 41 | Field string `json:"field"` 42 | StartDate string `json:"startDate"` 43 | EndDate string `json:"endDate"` 44 | } 45 | -------------------------------------------------------------------------------- /src/infrastructure/rest/controllers/BindTools_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func setupGinContext() (*gin.Context, *httptest.ResponseRecorder) { 14 | gin.SetMode(gin.TestMode) 15 | w := httptest.NewRecorder() 16 | c, _ := gin.CreateTestContext(w) 17 | return c, w 18 | } 19 | 20 | func TestBindJSON(t *testing.T) { 21 | // Test valid JSON 22 | validJSON := `{"name": "test", "email": "test@example.com"}` 23 | 24 | c, _ := setupGinContext() 25 | c.Request = httptest.NewRequest("POST", "/test", bytes.NewBufferString(validJSON)) 26 | c.Request.Header.Set("Content-Type", "application/json") 27 | 28 | var request map[string]string 29 | err := BindJSON(c, &request) 30 | 31 | assert.NoError(t, err) 32 | assert.Equal(t, "test", request["name"]) 33 | assert.Equal(t, "test@example.com", request["email"]) 34 | 35 | // Test invalid JSON 36 | invalidJSON := `{"name": "test", "email": "test@example.com"` 37 | 38 | c, _ = setupGinContext() 39 | c.Request = httptest.NewRequest("POST", "/test", bytes.NewBufferString(invalidJSON)) 40 | c.Request.Header.Set("Content-Type", "application/json") 41 | 42 | err = BindJSON(c, &request) 43 | 44 | assert.Error(t, err) 45 | 46 | // Test empty body 47 | c, _ = setupGinContext() 48 | c.Request = httptest.NewRequest("POST", "/test", bytes.NewBufferString("")) 49 | c.Request.Header.Set("Content-Type", "application/json") 50 | 51 | err = BindJSON(c, &request) 52 | 53 | assert.Error(t, err) 54 | } 55 | 56 | func TestBindJSONMap(t *testing.T) { 57 | // Test valid JSON 58 | validJSON := `{"name": "test", "email": "test@example.com", "age": 25}` 59 | 60 | c, _ := setupGinContext() 61 | c.Request = httptest.NewRequest("POST", "/test", bytes.NewBufferString(validJSON)) 62 | c.Request.Header.Set("Content-Type", "application/json") 63 | 64 | var request map[string]any 65 | err := BindJSONMap(c, &request) 66 | 67 | assert.NoError(t, err) 68 | assert.Equal(t, "test", request["name"]) 69 | assert.Equal(t, "test@example.com", request["email"]) 70 | assert.Equal(t, float64(25), request["age"]) // JSON numbers are unmarshaled as float64 71 | 72 | // Test invalid JSON 73 | invalidJSON := `{"name": "test", "email": "test@example.com"` 74 | 75 | c, _ = setupGinContext() 76 | c.Request = httptest.NewRequest("POST", "/test", bytes.NewBufferString(invalidJSON)) 77 | c.Request.Header.Set("Content-Type", "application/json") 78 | 79 | err = BindJSONMap(c, &request) 80 | 81 | assert.Error(t, err) 82 | 83 | // Test empty body 84 | c, _ = setupGinContext() 85 | c.Request = httptest.NewRequest("POST", "/test", bytes.NewBufferString("")) 86 | c.Request.Header.Set("Content-Type", "application/json") 87 | 88 | err = BindJSONMap(c, &request) 89 | 90 | assert.Error(t, err) 91 | } 92 | 93 | func TestPaginationValues(t *testing.T) { 94 | // Test case 1: Normal pagination 95 | numPages, nextCursor, prevCursor := PaginationValues(10, 2, 25) 96 | assert.Equal(t, int64(3), numPages) // (25 + 10 - 1) / 10 = 34 / 10 = 3 97 | assert.Equal(t, int64(3), nextCursor) // 2 + 1 = 3 98 | assert.Equal(t, int64(1), prevCursor) // 2 - 1 = 1 99 | 100 | // Test case 2: First page 101 | numPages, nextCursor, prevCursor = PaginationValues(10, 1, 25) 102 | assert.Equal(t, int64(3), numPages) // (25 + 10 - 1) / 10 = 34 / 10 = 3 103 | assert.Equal(t, int64(2), nextCursor) // 1 + 1 = 2 104 | assert.Equal(t, int64(0), prevCursor) // 1 - 1 = 0 (but should be 0 for first page) 105 | 106 | // Test case 3: Last page 107 | numPages, nextCursor, prevCursor = PaginationValues(10, 3, 25) 108 | assert.Equal(t, int64(3), numPages) // (25 + 10 - 1) / 10 = 34 / 10 = 3 109 | assert.Equal(t, int64(0), nextCursor) // 3 >= 3, so no next page 110 | assert.Equal(t, int64(2), prevCursor) // 3 - 1 = 2 111 | 112 | // Test case 4: Single page 113 | numPages, nextCursor, prevCursor = PaginationValues(10, 1, 5) 114 | assert.Equal(t, int64(1), numPages) // (5 + 10 - 1) / 10 = 14 / 10 = 1 115 | assert.Equal(t, int64(0), nextCursor) // 1 >= 1, so no next page 116 | assert.Equal(t, int64(0), prevCursor) // 1 - 1 = 0 117 | 118 | // Test case 5: Empty result 119 | numPages, nextCursor, prevCursor = PaginationValues(10, 1, 0) 120 | assert.Equal(t, int64(0), numPages) // (0 + 10 - 1) / 10 = 9 / 10 = 0 121 | assert.Equal(t, int64(0), nextCursor) // 1 >= 0, so no next page 122 | assert.Equal(t, int64(0), prevCursor) // 1 - 1 = 0 123 | 124 | // Test case 6: Large numbers 125 | numPages, nextCursor, prevCursor = PaginationValues(100, 5, 1000) 126 | assert.Equal(t, int64(10), numPages) // (1000 + 100 - 1) / 100 = 1099 / 100 = 10 127 | assert.Equal(t, int64(6), nextCursor) // 5 + 1 = 6 128 | assert.Equal(t, int64(4), prevCursor) // 5 - 1 = 4 129 | } 130 | 131 | func TestMessageResponse(t *testing.T) { 132 | message := MessageResponse{ 133 | Message: "Test message", 134 | } 135 | 136 | // Test JSON marshaling 137 | jsonData, err := json.Marshal(message) 138 | assert.NoError(t, err) 139 | 140 | var unmarshaled MessageResponse 141 | err = json.Unmarshal(jsonData, &unmarshaled) 142 | assert.NoError(t, err) 143 | assert.Equal(t, message.Message, unmarshaled.Message) 144 | } 145 | 146 | func TestSortByDataRequest(t *testing.T) { 147 | sortRequest := SortByDataRequest{ 148 | Field: "name", 149 | Direction: "asc", 150 | } 151 | 152 | // Test JSON marshaling 153 | jsonData, err := json.Marshal(sortRequest) 154 | assert.NoError(t, err) 155 | 156 | var unmarshaled SortByDataRequest 157 | err = json.Unmarshal(jsonData, &unmarshaled) 158 | assert.NoError(t, err) 159 | assert.Equal(t, sortRequest.Field, unmarshaled.Field) 160 | assert.Equal(t, sortRequest.Direction, unmarshaled.Direction) 161 | } 162 | 163 | func TestFieldDateRangeDataRequest(t *testing.T) { 164 | dateRangeRequest := FieldDateRangeDataRequest{ 165 | Field: "created_at", 166 | StartDate: "2023-01-01", 167 | EndDate: "2023-12-31", 168 | } 169 | 170 | // Test JSON marshaling 171 | jsonData, err := json.Marshal(dateRangeRequest) 172 | assert.NoError(t, err) 173 | 174 | var unmarshaled FieldDateRangeDataRequest 175 | err = json.Unmarshal(jsonData, &unmarshaled) 176 | assert.NoError(t, err) 177 | assert.Equal(t, dateRangeRequest.Field, unmarshaled.Field) 178 | assert.Equal(t, dateRangeRequest.StartDate, unmarshaled.StartDate) 179 | assert.Equal(t, dateRangeRequest.EndDate, unmarshaled.EndDate) 180 | } 181 | -------------------------------------------------------------------------------- /src/infrastructure/rest/controllers/Utils.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | func PaginationValues(limit int64, page int64, total int64) (numPages int64, nextCursor int64, prevCursor int64) { 4 | numPages = (total + limit - 1) / limit 5 | if page < numPages { 6 | nextCursor = page + 1 7 | } 8 | if page > 1 { 9 | prevCursor = page - 1 10 | } 11 | return 12 | } 13 | -------------------------------------------------------------------------------- /src/infrastructure/rest/controllers/auth/Auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | 6 | useCaseAuth "github.com/gbrayhan/microservices-go/src/application/usecases/auth" 7 | domainErrors "github.com/gbrayhan/microservices-go/src/domain/errors" 8 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 9 | "github.com/gbrayhan/microservices-go/src/infrastructure/rest/controllers" 10 | "github.com/gin-gonic/gin" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | type IAuthController interface { 15 | Login(ctx *gin.Context) 16 | GetAccessTokenByRefreshToken(ctx *gin.Context) 17 | } 18 | 19 | type AuthController struct { 20 | authUseCase useCaseAuth.IAuthUseCase 21 | Logger *logger.Logger 22 | } 23 | 24 | func NewAuthController(authUsecase useCaseAuth.IAuthUseCase, loggerInstance *logger.Logger) IAuthController { 25 | return &AuthController{ 26 | authUseCase: authUsecase, 27 | Logger: loggerInstance, 28 | } 29 | } 30 | 31 | func (c *AuthController) Login(ctx *gin.Context) { 32 | c.Logger.Info("User login request") 33 | var request LoginRequest 34 | if err := controllers.BindJSON(ctx, &request); err != nil { 35 | c.Logger.Error("Error binding JSON for login", zap.Error(err)) 36 | appError := domainErrors.NewAppError(err, domainErrors.ValidationError) 37 | _ = ctx.Error(appError) 38 | return 39 | } 40 | 41 | domainUser, authTokens, err := c.authUseCase.Login(request.Email, request.Password) 42 | if err != nil { 43 | c.Logger.Error("Login failed", zap.Error(err), zap.String("email", request.Email)) 44 | _ = ctx.Error(err) 45 | return 46 | } 47 | 48 | response := LoginResponse{ 49 | Data: UserData{ 50 | UserName: domainUser.UserName, 51 | Email: domainUser.Email, 52 | FirstName: domainUser.FirstName, 53 | LastName: domainUser.LastName, 54 | Status: domainUser.Status, 55 | ID: domainUser.ID, 56 | }, 57 | Security: SecurityData{ 58 | JWTAccessToken: authTokens.AccessToken, 59 | JWTRefreshToken: authTokens.RefreshToken, 60 | ExpirationAccessDateTime: authTokens.ExpirationAccessDateTime, 61 | ExpirationRefreshDateTime: authTokens.ExpirationRefreshDateTime, 62 | }, 63 | } 64 | 65 | c.Logger.Info("Login successful", zap.String("email", request.Email), zap.Int("userID", domainUser.ID)) 66 | ctx.JSON(http.StatusOK, response) 67 | } 68 | 69 | func (c *AuthController) GetAccessTokenByRefreshToken(ctx *gin.Context) { 70 | c.Logger.Info("Token refresh request") 71 | var request AccessTokenRequest 72 | if err := controllers.BindJSON(ctx, &request); err != nil { 73 | c.Logger.Error("Error binding JSON for token refresh", zap.Error(err)) 74 | appError := domainErrors.NewAppError(err, domainErrors.ValidationError) 75 | _ = ctx.Error(appError) 76 | return 77 | } 78 | 79 | domainUser, authTokens, err := c.authUseCase.AccessTokenByRefreshToken(request.RefreshToken) 80 | if err != nil { 81 | c.Logger.Error("Token refresh failed", zap.Error(err)) 82 | _ = ctx.Error(err) 83 | return 84 | } 85 | 86 | response := LoginResponse{ 87 | Data: UserData{ 88 | UserName: domainUser.UserName, 89 | Email: domainUser.Email, 90 | FirstName: domainUser.FirstName, 91 | LastName: domainUser.LastName, 92 | Status: domainUser.Status, 93 | ID: domainUser.ID, 94 | }, 95 | Security: SecurityData{ 96 | JWTAccessToken: authTokens.AccessToken, 97 | JWTRefreshToken: authTokens.RefreshToken, 98 | ExpirationAccessDateTime: authTokens.ExpirationAccessDateTime, 99 | ExpirationRefreshDateTime: authTokens.ExpirationRefreshDateTime, 100 | }, 101 | } 102 | 103 | c.Logger.Info("Token refresh successful", zap.Int("userID", domainUser.ID)) 104 | ctx.JSON(http.StatusOK, response) 105 | } 106 | -------------------------------------------------------------------------------- /src/infrastructure/rest/controllers/auth/Auth_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | useCaseAuth "github.com/gbrayhan/microservices-go/src/application/usecases/auth" 12 | userDomain "github.com/gbrayhan/microservices-go/src/domain/user" 13 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 14 | "github.com/gin-gonic/gin" 15 | ) 16 | 17 | // MockAuthUseCase implements IAuthUseCase for testing 18 | type MockAuthUseCase struct { 19 | loginFunc func(string, string) (*userDomain.User, *useCaseAuth.AuthTokens, error) 20 | accessTokenByRefreshFunc func(string) (*userDomain.User, *useCaseAuth.AuthTokens, error) 21 | } 22 | 23 | func (m *MockAuthUseCase) Login(email, password string) (*userDomain.User, *useCaseAuth.AuthTokens, error) { 24 | if m.loginFunc != nil { 25 | return m.loginFunc(email, password) 26 | } 27 | return nil, nil, nil 28 | } 29 | 30 | func (m *MockAuthUseCase) AccessTokenByRefreshToken(refreshToken string) (*userDomain.User, *useCaseAuth.AuthTokens, error) { 31 | if m.accessTokenByRefreshFunc != nil { 32 | return m.accessTokenByRefreshFunc(refreshToken) 33 | } 34 | return nil, nil, nil 35 | } 36 | 37 | func setupLogger(t *testing.T) *logger.Logger { 38 | loggerInstance, err := logger.NewLogger() 39 | if err != nil { 40 | t.Fatalf("Failed to create logger: %v", err) 41 | } 42 | return loggerInstance 43 | } 44 | 45 | func TestNewAuthController(t *testing.T) { 46 | mockUseCase := &MockAuthUseCase{} 47 | logger := setupLogger(t) 48 | controller := NewAuthController(mockUseCase, logger) 49 | 50 | if controller == nil { 51 | t.Error("Expected NewAuthController to return a non-nil controller") 52 | } 53 | } 54 | 55 | func TestAuthController_Login_Success(t *testing.T) { 56 | // Set Gin to test mode 57 | gin.SetMode(gin.TestMode) 58 | 59 | // Create mock use case 60 | mockUseCase := &MockAuthUseCase{ 61 | loginFunc: func(email, password string) (*userDomain.User, *useCaseAuth.AuthTokens, error) { 62 | user := &userDomain.User{ 63 | UserName: "testuser", 64 | Email: "test@example.com", 65 | FirstName: "Test", 66 | LastName: "User", 67 | Status: true, 68 | ID: 1, 69 | } 70 | authTokens := &useCaseAuth.AuthTokens{ 71 | AccessToken: "test-access-token", 72 | RefreshToken: "test-refresh-token", 73 | ExpirationAccessDateTime: time.Now().Add(time.Hour), 74 | ExpirationRefreshDateTime: time.Now().Add(24 * time.Hour), 75 | } 76 | return user, authTokens, nil 77 | }, 78 | } 79 | 80 | // Create controller 81 | logger := setupLogger(t) 82 | controller := NewAuthController(mockUseCase, logger) 83 | 84 | // Create test request 85 | loginRequest := LoginRequest{ 86 | Email: "test@example.com", 87 | Password: "password123", 88 | } 89 | 90 | requestBody, _ := json.Marshal(loginRequest) 91 | 92 | // Create HTTP request 93 | w := httptest.NewRecorder() 94 | req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(requestBody)) 95 | req.Header.Set("Content-Type", "application/json") 96 | 97 | // Create Gin context 98 | c, _ := gin.CreateTestContext(w) 99 | c.Request = req 100 | 101 | // Call the method 102 | controller.Login(c) 103 | 104 | // Check response 105 | if w.Code != http.StatusOK { 106 | t.Errorf("Expected status 200, got %d", w.Code) 107 | } 108 | } 109 | 110 | func TestAuthController_Login_InvalidRequest(t *testing.T) { 111 | // Set Gin to test mode 112 | gin.SetMode(gin.TestMode) 113 | 114 | // Create mock use case 115 | mockUseCase := &MockAuthUseCase{} 116 | 117 | // Create controller 118 | logger := setupLogger(t) 119 | controller := NewAuthController(mockUseCase, logger) 120 | 121 | // Create invalid request (missing required fields) 122 | requestBody := []byte(`{"email": "test@example.com"}`) // Missing password 123 | 124 | // Create HTTP request 125 | w := httptest.NewRecorder() 126 | req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(requestBody)) 127 | req.Header.Set("Content-Type", "application/json") 128 | 129 | // Create Gin context 130 | c, _ := gin.CreateTestContext(w) 131 | c.Request = req 132 | 133 | // Call the method 134 | controller.Login(c) 135 | 136 | // Check that an error was added to the context 137 | if len(c.Errors) == 0 { 138 | t.Error("Expected error to be added to context") 139 | } 140 | } 141 | 142 | func TestAuthController_GetAccessTokenByRefreshToken_Success(t *testing.T) { 143 | // Set Gin to test mode 144 | gin.SetMode(gin.TestMode) 145 | 146 | // Create mock use case 147 | mockUseCase := &MockAuthUseCase{ 148 | accessTokenByRefreshFunc: func(refreshToken string) (*userDomain.User, *useCaseAuth.AuthTokens, error) { 149 | user := &userDomain.User{ 150 | UserName: "testuser", 151 | Email: "test@example.com", 152 | FirstName: "Test", 153 | LastName: "User", 154 | Status: true, 155 | ID: 1, 156 | } 157 | authTokens := &useCaseAuth.AuthTokens{ 158 | AccessToken: "new-access-token", 159 | RefreshToken: "new-refresh-token", 160 | ExpirationAccessDateTime: time.Now().Add(time.Hour), 161 | ExpirationRefreshDateTime: time.Now().Add(24 * time.Hour), 162 | } 163 | return user, authTokens, nil 164 | }, 165 | } 166 | 167 | // Create controller 168 | logger := setupLogger(t) 169 | controller := NewAuthController(mockUseCase, logger) 170 | 171 | // Create test request 172 | accessTokenRequest := AccessTokenRequest{ 173 | RefreshToken: "test-refresh-token", 174 | } 175 | 176 | requestBody, _ := json.Marshal(accessTokenRequest) 177 | 178 | // Create HTTP request 179 | w := httptest.NewRecorder() 180 | req, _ := http.NewRequest("POST", "/refresh", bytes.NewBuffer(requestBody)) 181 | req.Header.Set("Content-Type", "application/json") 182 | 183 | // Create Gin context 184 | c, _ := gin.CreateTestContext(w) 185 | c.Request = req 186 | 187 | // Call the method 188 | controller.GetAccessTokenByRefreshToken(c) 189 | 190 | // Check response 191 | if w.Code != http.StatusOK { 192 | t.Errorf("Expected status 200, got %d", w.Code) 193 | } 194 | } 195 | 196 | func TestAuthController_GetAccessTokenByRefreshToken_InvalidRequest(t *testing.T) { 197 | // Set Gin to test mode 198 | gin.SetMode(gin.TestMode) 199 | 200 | // Create mock use case 201 | mockUseCase := &MockAuthUseCase{} 202 | 203 | // Create controller 204 | logger := setupLogger(t) 205 | controller := NewAuthController(mockUseCase, logger) 206 | 207 | // Create invalid request (missing required fields) 208 | requestBody := []byte(`{}`) // Missing refreshToken 209 | 210 | // Create HTTP request 211 | w := httptest.NewRecorder() 212 | req, _ := http.NewRequest("POST", "/refresh", bytes.NewBuffer(requestBody)) 213 | req.Header.Set("Content-Type", "application/json") 214 | 215 | // Create Gin context 216 | c, _ := gin.CreateTestContext(w) 217 | c.Request = req 218 | 219 | // Call the method 220 | controller.GetAccessTokenByRefreshToken(c) 221 | 222 | // Check that an error was added to the context 223 | if len(c.Errors) == 0 { 224 | t.Error("Expected error to be added to context") 225 | } 226 | } 227 | 228 | func TestLoginRequest_Validation(t *testing.T) { 229 | // Test valid request 230 | validRequest := LoginRequest{ 231 | Email: "test@example.com", 232 | Password: "password123", 233 | } 234 | 235 | if validRequest.Email == "" { 236 | t.Error("Email should not be empty") 237 | } 238 | 239 | if validRequest.Password == "" { 240 | t.Error("Password should not be empty") 241 | } 242 | 243 | // Test invalid email format (basic check) 244 | if validRequest.Email == "invalid-email" { 245 | t.Error("Email should be in valid format") 246 | } 247 | } 248 | 249 | func TestAccessTokenRequest_Validation(t *testing.T) { 250 | // Test valid request 251 | validRequest := AccessTokenRequest{ 252 | RefreshToken: "valid-refresh-token", 253 | } 254 | 255 | if validRequest.RefreshToken == "" { 256 | t.Error("RefreshToken should not be empty") 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/infrastructure/rest/controllers/auth/Structures.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "time" 4 | 5 | type LoginRequest struct { 6 | Email string `json:"email" binding:"required"` 7 | Password string `json:"password" binding:"required"` 8 | } 9 | 10 | type AccessTokenRequest struct { 11 | RefreshToken string `json:"refreshToken" binding:"required"` 12 | } 13 | 14 | type UserData struct { 15 | UserName string `json:"userName"` 16 | Email string `json:"email"` 17 | FirstName string `json:"firstName"` 18 | LastName string `json:"lastName"` 19 | Status bool `json:"status"` 20 | ID int `json:"id"` 21 | } 22 | 23 | type SecurityData struct { 24 | JWTAccessToken string `json:"jwtAccessToken"` 25 | JWTRefreshToken string `json:"jwtRefreshToken"` 26 | ExpirationAccessDateTime time.Time `json:"expirationAccessDateTime"` 27 | ExpirationRefreshDateTime time.Time `json:"expirationRefreshDateTime"` 28 | } 29 | 30 | type LoginResponse struct { 31 | Data UserData `json:"data"` 32 | Security SecurityData `json:"security"` 33 | } 34 | -------------------------------------------------------------------------------- /src/infrastructure/rest/controllers/medicine/Validation.go: -------------------------------------------------------------------------------- 1 | package medicine 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | domainErrors "github.com/gbrayhan/microservices-go/src/domain/errors" 9 | "github.com/go-playground/validator/v10" 10 | ) 11 | 12 | func updateValidation(request map[string]any) error { 13 | var errorsValidation []string 14 | for k, v := range request { 15 | if v == "" { 16 | errorsValidation = append(errorsValidation, fmt.Sprintf("%s cannot be empty", k)) 17 | } 18 | } 19 | 20 | validationMap := map[string]string{ 21 | "name": "omitempty,gt=3,lt=100", 22 | "description": "omitempty,gt=3,lt=100", 23 | "ean_code": "omitempty,gt=3,lt=100", 24 | "laboratory": "omitempty,gt=3,lt=100", 25 | } 26 | 27 | validate := validator.New() 28 | err := validate.RegisterValidation("update_validation", func(fl validator.FieldLevel) bool { 29 | m, ok := fl.Field().Interface().(map[string]any) 30 | if !ok { 31 | return false 32 | } 33 | for k, rule := range validationMap { 34 | if val, exists := m[k]; exists { 35 | errValidate := validate.Var(val, rule) 36 | if errValidate != nil { 37 | validatorErr := errValidate.(validator.ValidationErrors) 38 | errorsValidation = append( 39 | errorsValidation, 40 | fmt.Sprintf("%s do not satisfy condition %v=%v", k, validatorErr[0].Tag(), validatorErr[0].Param()), 41 | ) 42 | } 43 | } 44 | } 45 | return true 46 | }) 47 | if err != nil { 48 | return domainErrors.NewAppError(err, domainErrors.UnknownError) 49 | } 50 | 51 | err = validate.Var(request, "update_validation") 52 | if err != nil { 53 | return domainErrors.NewAppError(err, domainErrors.UnknownError) 54 | } 55 | if len(errorsValidation) > 0 { 56 | return domainErrors.NewAppError(errors.New(strings.Join(errorsValidation, ", ")), domainErrors.ValidationError) 57 | } 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /src/infrastructure/rest/controllers/user/User.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/gbrayhan/microservices-go/src/domain" 10 | domainErrors "github.com/gbrayhan/microservices-go/src/domain/errors" 11 | domainUser "github.com/gbrayhan/microservices-go/src/domain/user" 12 | logger "github.com/gbrayhan/microservices-go/src/infrastructure/logger" 13 | "github.com/gbrayhan/microservices-go/src/infrastructure/repository/psql/user" 14 | "github.com/gbrayhan/microservices-go/src/infrastructure/rest/controllers" 15 | "github.com/gin-gonic/gin" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | // Structures 20 | type NewUserRequest struct { 21 | UserName string `json:"user" binding:"required"` 22 | Email string `json:"email" binding:"required"` 23 | FirstName string `json:"firstName" binding:"required"` 24 | LastName string `json:"lastName" binding:"required"` 25 | Password string `json:"password" binding:"required"` 26 | Role string `json:"role" binding:"required"` 27 | } 28 | 29 | type ResponseUser struct { 30 | ID int `json:"id"` 31 | UserName string `json:"user"` 32 | Email string `json:"email"` 33 | FirstName string `json:"firstName"` 34 | LastName string `json:"lastName"` 35 | Status bool `json:"status"` 36 | CreatedAt time.Time `json:"createdAt,omitempty"` 37 | UpdatedAt time.Time `json:"updatedAt,omitempty"` 38 | } 39 | 40 | type IUserController interface { 41 | NewUser(ctx *gin.Context) 42 | GetAllUsers(ctx *gin.Context) 43 | GetUsersByID(ctx *gin.Context) 44 | UpdateUser(ctx *gin.Context) 45 | DeleteUser(ctx *gin.Context) 46 | SearchPaginated(ctx *gin.Context) 47 | SearchByProperty(ctx *gin.Context) 48 | } 49 | 50 | type UserController struct { 51 | userService domainUser.IUserService 52 | Logger *logger.Logger 53 | } 54 | 55 | func NewUserController(userService domainUser.IUserService, loggerInstance *logger.Logger) IUserController { 56 | return &UserController{userService: userService, Logger: loggerInstance} 57 | } 58 | 59 | func (c *UserController) NewUser(ctx *gin.Context) { 60 | c.Logger.Info("Creating new user") 61 | var request NewUserRequest 62 | if err := controllers.BindJSON(ctx, &request); err != nil { 63 | c.Logger.Error("Error binding JSON for new user", zap.Error(err)) 64 | appError := domainErrors.NewAppError(err, domainErrors.ValidationError) 65 | _ = ctx.Error(appError) 66 | return 67 | } 68 | userModel, err := c.userService.Create(toUsecaseMapper(&request)) 69 | if err != nil { 70 | c.Logger.Error("Error creating user", zap.Error(err), zap.String("email", request.Email)) 71 | _ = ctx.Error(err) 72 | return 73 | } 74 | userResponse := domainToResponseMapper(userModel) 75 | c.Logger.Info("User created successfully", zap.String("email", request.Email), zap.Int("id", userModel.ID)) 76 | ctx.JSON(http.StatusOK, userResponse) 77 | } 78 | 79 | func (c *UserController) GetAllUsers(ctx *gin.Context) { 80 | c.Logger.Info("Getting all users") 81 | users, err := c.userService.GetAll() 82 | if err != nil { 83 | c.Logger.Error("Error getting all users", zap.Error(err)) 84 | appError := domainErrors.NewAppErrorWithType(domainErrors.UnknownError) 85 | _ = ctx.Error(appError) 86 | return 87 | } 88 | c.Logger.Info("Successfully retrieved all users", zap.Int("count", len(*users))) 89 | ctx.JSON(http.StatusOK, arrayDomainToResponseMapper(users)) 90 | } 91 | 92 | func (c *UserController) GetUsersByID(ctx *gin.Context) { 93 | userID, err := strconv.Atoi(ctx.Param("id")) 94 | if err != nil { 95 | c.Logger.Error("Invalid user ID parameter", zap.Error(err), zap.String("id", ctx.Param("id"))) 96 | appError := domainErrors.NewAppError(errors.New("user id is invalid"), domainErrors.ValidationError) 97 | _ = ctx.Error(appError) 98 | return 99 | } 100 | c.Logger.Info("Getting user by ID", zap.Int("id", userID)) 101 | user, err := c.userService.GetByID(userID) 102 | if err != nil { 103 | c.Logger.Error("Error getting user by ID", zap.Error(err), zap.Int("id", userID)) 104 | _ = ctx.Error(err) 105 | return 106 | } 107 | c.Logger.Info("Successfully retrieved user by ID", zap.Int("id", userID)) 108 | ctx.JSON(http.StatusOK, domainToResponseMapper(user)) 109 | } 110 | 111 | func (c *UserController) UpdateUser(ctx *gin.Context) { 112 | userID, err := strconv.Atoi(ctx.Param("id")) 113 | if err != nil { 114 | c.Logger.Error("Invalid user ID parameter for update", zap.Error(err), zap.String("id", ctx.Param("id"))) 115 | appError := domainErrors.NewAppError(errors.New("param id is necessary"), domainErrors.ValidationError) 116 | _ = ctx.Error(appError) 117 | return 118 | } 119 | c.Logger.Info("Updating user", zap.Int("id", userID)) 120 | var requestMap map[string]any 121 | err = controllers.BindJSONMap(ctx, &requestMap) 122 | if err != nil { 123 | c.Logger.Error("Error binding JSON for user update", zap.Error(err), zap.Int("id", userID)) 124 | appError := domainErrors.NewAppError(err, domainErrors.ValidationError) 125 | _ = ctx.Error(appError) 126 | return 127 | } 128 | err = updateValidation(requestMap) 129 | if err != nil { 130 | c.Logger.Error("Validation error for user update", zap.Error(err), zap.Int("id", userID)) 131 | _ = ctx.Error(err) 132 | return 133 | } 134 | userUpdated, err := c.userService.Update(userID, requestMap) 135 | if err != nil { 136 | c.Logger.Error("Error updating user", zap.Error(err), zap.Int("id", userID)) 137 | _ = ctx.Error(err) 138 | return 139 | } 140 | c.Logger.Info("User updated successfully", zap.Int("id", userID)) 141 | ctx.JSON(http.StatusOK, domainToResponseMapper(userUpdated)) 142 | } 143 | 144 | func (c *UserController) DeleteUser(ctx *gin.Context) { 145 | userID, err := strconv.Atoi(ctx.Param("id")) 146 | if err != nil { 147 | c.Logger.Error("Invalid user ID parameter for deletion", zap.Error(err), zap.String("id", ctx.Param("id"))) 148 | appError := domainErrors.NewAppError(errors.New("param id is necessary"), domainErrors.ValidationError) 149 | _ = ctx.Error(appError) 150 | return 151 | } 152 | c.Logger.Info("Deleting user", zap.Int("id", userID)) 153 | err = c.userService.Delete(userID) 154 | if err != nil { 155 | c.Logger.Error("Error deleting user", zap.Error(err), zap.Int("id", userID)) 156 | _ = ctx.Error(err) 157 | return 158 | } 159 | c.Logger.Info("User deleted successfully", zap.Int("id", userID)) 160 | ctx.JSON(http.StatusOK, gin.H{"message": "resource deleted successfully"}) 161 | } 162 | 163 | func (c *UserController) SearchPaginated(ctx *gin.Context) { 164 | c.Logger.Info("Searching users with pagination") 165 | 166 | // Parse query parameters 167 | page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) 168 | if page < 1 { 169 | page = 1 170 | } 171 | pageSize, _ := strconv.Atoi(ctx.DefaultQuery("pageSize", "10")) 172 | if pageSize < 1 { 173 | pageSize = 10 174 | } 175 | 176 | // Build filters 177 | filters := domain.DataFilters{ 178 | Page: page, 179 | PageSize: pageSize, 180 | } 181 | 182 | // Parse like filters 183 | likeFilters := make(map[string][]string) 184 | for field := range user.ColumnsUserMapping { 185 | if values := ctx.QueryArray(field + "_like"); len(values) > 0 { 186 | likeFilters[field] = values 187 | } 188 | } 189 | filters.LikeFilters = likeFilters 190 | 191 | // Parse exact matches 192 | matches := make(map[string][]string) 193 | for field := range user.ColumnsUserMapping { 194 | if values := ctx.QueryArray(field + "_match"); len(values) > 0 { 195 | matches[field] = values 196 | } 197 | } 198 | filters.Matches = matches 199 | 200 | // Parse date range filters 201 | var dateRanges []domain.DateRangeFilter 202 | for field := range user.ColumnsUserMapping { 203 | startStr := ctx.Query(field + "_start") 204 | endStr := ctx.Query(field + "_end") 205 | 206 | if startStr != "" || endStr != "" { 207 | dateRange := domain.DateRangeFilter{Field: field} 208 | 209 | if startStr != "" { 210 | if startTime, err := time.Parse(time.RFC3339, startStr); err == nil { 211 | dateRange.Start = &startTime 212 | } 213 | } 214 | 215 | if endStr != "" { 216 | if endTime, err := time.Parse(time.RFC3339, endStr); err == nil { 217 | dateRange.End = &endTime 218 | } 219 | } 220 | 221 | dateRanges = append(dateRanges, dateRange) 222 | } 223 | } 224 | filters.DateRangeFilters = dateRanges 225 | 226 | // Parse sorting 227 | sortBy := ctx.QueryArray("sortBy") 228 | if len(sortBy) > 0 { 229 | filters.SortBy = sortBy 230 | } 231 | 232 | sortDirection := domain.SortDirection(ctx.DefaultQuery("sortDirection", "asc")) 233 | if sortDirection.IsValid() { 234 | filters.SortDirection = sortDirection 235 | } 236 | 237 | result, err := c.userService.SearchPaginated(filters) 238 | if err != nil { 239 | c.Logger.Error("Error searching users", zap.Error(err)) 240 | _ = ctx.Error(err) 241 | return 242 | } 243 | 244 | response := gin.H{ 245 | "data": arrayDomainToResponseMapper(result.Data), 246 | "total": result.Total, 247 | "page": result.Page, 248 | "pageSize": result.PageSize, 249 | "totalPages": result.TotalPages, 250 | "filters": filters, 251 | } 252 | 253 | c.Logger.Info("Successfully searched users", 254 | zap.Int64("total", result.Total), 255 | zap.Int("page", result.Page)) 256 | ctx.JSON(http.StatusOK, response) 257 | } 258 | 259 | func (c *UserController) SearchByProperty(ctx *gin.Context) { 260 | property := ctx.Query("property") 261 | searchText := ctx.Query("searchText") 262 | 263 | if property == "" || searchText == "" { 264 | c.Logger.Error("Missing property or searchText parameter") 265 | appError := domainErrors.NewAppError(errors.New("missing property or searchText parameter"), domainErrors.ValidationError) 266 | _ = ctx.Error(appError) 267 | return 268 | } 269 | 270 | // Validate property 271 | allowed := map[string]bool{ 272 | "userName": true, 273 | "email": true, 274 | "firstName": true, 275 | "lastName": true, 276 | "status": true, 277 | } 278 | if !allowed[property] { 279 | c.Logger.Error("Invalid property for search", zap.String("property", property)) 280 | appError := domainErrors.NewAppError(errors.New("invalid property"), domainErrors.ValidationError) 281 | _ = ctx.Error(appError) 282 | return 283 | } 284 | 285 | coincidences, err := c.userService.SearchByProperty(property, searchText) 286 | if err != nil { 287 | c.Logger.Error("Error searching by property", zap.Error(err), zap.String("property", property)) 288 | _ = ctx.Error(err) 289 | return 290 | } 291 | 292 | c.Logger.Info("Successfully searched by property", 293 | zap.String("property", property), 294 | zap.Int("results", len(*coincidences))) 295 | ctx.JSON(http.StatusOK, coincidences) 296 | } 297 | 298 | // Mappers 299 | func domainToResponseMapper(domainUser *domainUser.User) *ResponseUser { 300 | return &ResponseUser{ 301 | ID: domainUser.ID, 302 | UserName: domainUser.UserName, 303 | Email: domainUser.Email, 304 | FirstName: domainUser.FirstName, 305 | LastName: domainUser.LastName, 306 | Status: domainUser.Status, 307 | CreatedAt: domainUser.CreatedAt, 308 | UpdatedAt: domainUser.UpdatedAt, 309 | } 310 | } 311 | 312 | func arrayDomainToResponseMapper(users *[]domainUser.User) *[]ResponseUser { 313 | res := make([]ResponseUser, len(*users)) 314 | for i, u := range *users { 315 | res[i] = *domainToResponseMapper(&u) 316 | } 317 | return &res 318 | } 319 | 320 | func toUsecaseMapper(req *NewUserRequest) *domainUser.User { 321 | return &domainUser.User{ 322 | UserName: req.UserName, 323 | Email: req.Email, 324 | FirstName: req.FirstName, 325 | LastName: req.LastName, 326 | Password: req.Password, 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/infrastructure/rest/controllers/user/Validation.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | domainErrors "github.com/gbrayhan/microservices-go/src/domain/errors" 9 | "github.com/go-playground/validator/v10" 10 | ) 11 | 12 | func updateValidation(request map[string]any) error { 13 | var errorsValidation []string 14 | for k, v := range request { 15 | if v == "" { 16 | errorsValidation = append(errorsValidation, fmt.Sprintf("%s cannot be empty", k)) 17 | } 18 | } 19 | 20 | validationMap := map[string]string{ 21 | "user_name": "omitempty,gt=3,lt=100", 22 | "email": "omitempty,email", 23 | "firstName": "omitempty,gt=1,lt=100", 24 | "lastName": "omitempty,gt=1,lt=100", 25 | } 26 | 27 | validate := validator.New() 28 | err := validate.RegisterValidation("update_validation", func(fl validator.FieldLevel) bool { 29 | m, ok := fl.Field().Interface().(map[string]any) 30 | if !ok { 31 | return false 32 | } 33 | for k, rule := range validationMap { 34 | if val, exists := m[k]; exists { 35 | errValidate := validate.Var(val, rule) 36 | if errValidate != nil { 37 | validatorErr := errValidate.(validator.ValidationErrors) 38 | errorsValidation = append( 39 | errorsValidation, 40 | fmt.Sprintf("%s does not satisfy condition %v=%v", k, validatorErr[0].Tag(), validatorErr[0].Param()), 41 | ) 42 | } 43 | } 44 | } 45 | return true 46 | }) 47 | if err != nil { 48 | return domainErrors.NewAppError(err, domainErrors.UnknownError) 49 | } 50 | 51 | err = validate.Var(request, "update_validation") 52 | if err != nil { 53 | return domainErrors.NewAppError(err, domainErrors.UnknownError) 54 | } 55 | if len(errorsValidation) > 0 { 56 | return domainErrors.NewAppError(errors.New(strings.Join(errorsValidation, ", ")), domainErrors.ValidationError) 57 | } 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /src/infrastructure/rest/middlewares/Headers.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func CommonHeaders(c *gin.Context) { 6 | c.Header("Access-Control-Allow-Origin", "*") 7 | c.Header("Access-Control-Allow-Credentials", "true") 8 | c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, DELETE, GET, PUT") 9 | c.Header("Access-Control-Allow-Headers", 10 | "Content-Type, Depth, User-Agent, X-File-Size, X-Requested-With, If-Modified-Since, X-File-CompanyName, Cache-Control") 11 | c.Header("X-Frame-Options", "SAMEORIGIN") 12 | c.Header("Cache-Control", "no-cache, no-store") 13 | c.Header("Pragma", "no-cache") 14 | c.Header("Expires", "0") 15 | 16 | c.Next() 17 | } 18 | -------------------------------------------------------------------------------- /src/infrastructure/rest/middlewares/Headers_test.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func TestCommonHeaders(t *testing.T) { 12 | // Set Gin to test mode 13 | gin.SetMode(gin.TestMode) 14 | 15 | // Create a new Gin router 16 | router := gin.New() 17 | router.Use(CommonHeaders) 18 | 19 | // Add a test route 20 | router.GET("/test", func(c *gin.Context) { 21 | c.JSON(http.StatusOK, gin.H{"message": "test"}) 22 | }) 23 | 24 | // Create a test request 25 | w := httptest.NewRecorder() 26 | req, _ := http.NewRequest("GET", "/test", nil) 27 | 28 | // Serve the request 29 | router.ServeHTTP(w, req) 30 | 31 | // Check headers 32 | headers := w.Header() 33 | 34 | expectedHeaders := map[string]string{ 35 | "Access-Control-Allow-Origin": "*", 36 | "Access-Control-Allow-Credentials": "true", 37 | "Access-Control-Allow-Methods": "POST, OPTIONS, DELETE, GET, PUT", 38 | "X-Frame-Options": "SAMEORIGIN", 39 | "Cache-Control": "no-cache, no-store", 40 | "Pragma": "no-cache", 41 | "Expires": "0", 42 | } 43 | 44 | for key, expectedValue := range expectedHeaders { 45 | actualValue := headers.Get(key) 46 | if actualValue != expectedValue { 47 | t.Errorf("Header %s: expected %s, got %s", key, expectedValue, actualValue) 48 | } 49 | } 50 | 51 | // Check Access-Control-Allow-Headers (it's a long header) 52 | allowHeaders := headers.Get("Access-Control-Allow-Headers") 53 | expectedAllowHeaders := "Content-Type, Depth, User-Agent, X-File-Size, X-Requested-With, If-Modified-Since, X-File-CompanyName, Cache-Control" 54 | if allowHeaders != expectedAllowHeaders { 55 | t.Errorf("Access-Control-Allow-Headers: expected %s, got %s", expectedAllowHeaders, allowHeaders) 56 | } 57 | 58 | // Check response status 59 | if w.Code != http.StatusOK { 60 | t.Errorf("Expected status 200, got %d", w.Code) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/infrastructure/rest/middlewares/Interceptor.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type bodyLogWriter struct { 13 | gin.ResponseWriter 14 | body *bytes.Buffer 15 | } 16 | 17 | func (w bodyLogWriter) Write(b []byte) (int, error) { 18 | w.body.Write(b) 19 | return w.ResponseWriter.Write(b) 20 | } 21 | 22 | func GinBodyLogMiddleware(c *gin.Context) { 23 | blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer} 24 | c.Writer = blw 25 | 26 | buf := make([]byte, 4096) 27 | num, err := c.Request.Body.Read(buf) 28 | if err != nil && err.Error() != "EOF" { 29 | _ = fmt.Errorf("error reading buffer: %s", err.Error()) 30 | } 31 | reqBody := string(buf[0:num]) 32 | c.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(reqBody))) 33 | 34 | c.Next() 35 | 36 | loc, _ := time.LoadLocation("America/Mexico_City") 37 | allDataIO := map[string]any{ 38 | "ruta": c.FullPath(), 39 | "request_uri": c.Request.RequestURI, 40 | "raw_request": reqBody, 41 | "status_code": c.Writer.Status(), 42 | "body_response": blw.body.String(), 43 | "errors": c.Errors.Errors(), 44 | "created_at": time.Now().In(loc).Format("2006-01-02T15:04:05"), 45 | } 46 | _ = fmt.Sprintf("%v", allDataIO) 47 | } 48 | -------------------------------------------------------------------------------- /src/infrastructure/rest/middlewares/Interceptor_test.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "net" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | // MockResponseWriter implements gin.ResponseWriter for testing 17 | type MockResponseWriter struct { 18 | *httptest.ResponseRecorder 19 | } 20 | 21 | func (m *MockResponseWriter) CloseNotify() <-chan bool { 22 | return make(chan bool) 23 | } 24 | 25 | func (m *MockResponseWriter) Flush() { 26 | } 27 | 28 | func (m *MockResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 29 | return nil, nil, nil 30 | } 31 | 32 | func (m *MockResponseWriter) Size() int { 33 | return len(m.Body.Bytes()) 34 | } 35 | 36 | func (m *MockResponseWriter) Status() int { 37 | return m.Code 38 | } 39 | 40 | func (m *MockResponseWriter) WriteHeaderNow() { 41 | } 42 | 43 | func (m *MockResponseWriter) Written() bool { 44 | return m.Code != 0 45 | } 46 | 47 | func (m *MockResponseWriter) WriteString(string) (int, error) { 48 | return 0, nil 49 | } 50 | 51 | func (m *MockResponseWriter) WriteHeader(code int) { 52 | m.Code = code 53 | } 54 | 55 | func (m *MockResponseWriter) Pusher() http.Pusher { 56 | return nil 57 | } 58 | 59 | func TestGinBodyLogMiddleware(t *testing.T) { 60 | // Set Gin to test mode 61 | gin.SetMode(gin.TestMode) 62 | 63 | // Create a new Gin router 64 | router := gin.New() 65 | router.Use(GinBodyLogMiddleware) 66 | 67 | // Add a test route 68 | router.POST("/test", func(c *gin.Context) { 69 | c.JSON(http.StatusOK, gin.H{"message": "test response"}) 70 | }) 71 | 72 | // Create test request body 73 | requestBody := `{"test": "data"}` 74 | 75 | // Create a test request 76 | w := httptest.NewRecorder() 77 | req, _ := http.NewRequest("POST", "/test", strings.NewReader(requestBody)) 78 | req.Header.Set("Content-Type", "application/json") 79 | 80 | // Serve the request 81 | router.ServeHTTP(w, req) 82 | 83 | // Check response status 84 | if w.Code != http.StatusOK { 85 | t.Errorf("Expected status 200, got %d", w.Code) 86 | } 87 | 88 | // Check response body 89 | expectedResponse := `{"message":"test response"}` 90 | if !strings.Contains(w.Body.String(), expectedResponse) { 91 | t.Errorf("Expected response to contain %s, got %s", expectedResponse, w.Body.String()) 92 | } 93 | } 94 | 95 | func TestGinBodyLogMiddleware_EmptyBody(t *testing.T) { 96 | // Set Gin to test mode 97 | gin.SetMode(gin.TestMode) 98 | 99 | // Create a new Gin router 100 | router := gin.New() 101 | router.Use(GinBodyLogMiddleware) 102 | 103 | // Add a test route 104 | router.GET("/test", func(c *gin.Context) { 105 | c.JSON(http.StatusOK, gin.H{"message": "test"}) 106 | }) 107 | 108 | // Create a test request with empty body 109 | w := httptest.NewRecorder() 110 | req, _ := http.NewRequest("GET", "/test", nil) 111 | 112 | // Ensure the request body is properly initialized 113 | if req.Body == nil { 114 | req.Body = io.NopCloser(bytes.NewBuffer([]byte(""))) 115 | } 116 | 117 | // Serve the request 118 | router.ServeHTTP(w, req) 119 | 120 | // Check response status 121 | if w.Code != http.StatusOK { 122 | t.Errorf("Expected status 200, got %d", w.Code) 123 | } 124 | } 125 | 126 | func TestBodyLogWriter_Write(t *testing.T) { 127 | // Create a mock response writer 128 | mockWriter := &MockResponseWriter{ 129 | ResponseRecorder: httptest.NewRecorder(), 130 | } 131 | 132 | // Create bodyLogWriter 133 | blw := &bodyLogWriter{ 134 | ResponseWriter: mockWriter, 135 | body: bytes.NewBufferString(""), 136 | } 137 | 138 | // Test data to write 139 | testData := []byte("test response data") 140 | 141 | // Write data 142 | bytesWritten, err := blw.Write(testData) 143 | 144 | // Check no error 145 | if err != nil { 146 | t.Errorf("Expected no error, got %v", err) 147 | } 148 | 149 | // Check bytes written 150 | if bytesWritten != len(testData) { 151 | t.Errorf("Expected %d bytes written, got %d", len(testData), bytesWritten) 152 | } 153 | 154 | // Check body buffer contains the data 155 | if blw.body.String() != string(testData) { 156 | t.Errorf("Expected body to contain %s, got %s", string(testData), blw.body.String()) 157 | } 158 | 159 | // Check response writer also contains the data 160 | if mockWriter.Body.String() != string(testData) { 161 | t.Errorf("Expected response writer to contain %s, got %s", string(testData), mockWriter.Body.String()) 162 | } 163 | } 164 | 165 | func TestGinBodyLogMiddleware_LargeBody(t *testing.T) { 166 | // Set Gin to test mode 167 | gin.SetMode(gin.TestMode) 168 | 169 | // Create a new Gin router 170 | router := gin.New() 171 | router.Use(GinBodyLogMiddleware) 172 | 173 | // Add a test route 174 | router.POST("/test", func(c *gin.Context) { 175 | c.JSON(http.StatusOK, gin.H{"message": "large body test"}) 176 | }) 177 | 178 | // Create a large request body (larger than the 4096 buffer) 179 | largeBody := strings.Repeat("a", 5000) 180 | 181 | // Create a test request 182 | w := httptest.NewRecorder() 183 | req, _ := http.NewRequest("POST", "/test", strings.NewReader(largeBody)) 184 | 185 | // Serve the request 186 | router.ServeHTTP(w, req) 187 | 188 | // Check response status 189 | if w.Code != http.StatusOK { 190 | t.Errorf("Expected status 200, got %d", w.Code) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/infrastructure/rest/middlewares/RequiresLogin.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/golang-jwt/jwt/v4" 10 | ) 11 | 12 | func AuthJWTMiddleware() gin.HandlerFunc { 13 | return func(c *gin.Context) { 14 | tokenString := c.GetHeader("Authorization") 15 | if tokenString == "" { 16 | c.JSON(http.StatusUnauthorized, gin.H{"error": "Token not provided"}) 17 | c.Abort() 18 | return 19 | } 20 | 21 | accessSecret := os.Getenv("JWT_ACCESS_SECRET_KEY") 22 | if accessSecret == "" { 23 | c.JSON(http.StatusUnauthorized, gin.H{"error": "JWT_ACCESS_SECRET_KEY not configured"}) 24 | c.Abort() 25 | return 26 | } 27 | 28 | tokenString = strings.TrimPrefix(tokenString, "Bearer ") 29 | claims := jwt.MapClaims{} 30 | _, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { 31 | return []byte(accessSecret), nil 32 | }) 33 | if err != nil { 34 | c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) 35 | c.Abort() 36 | return 37 | } 38 | 39 | if exp, ok := claims["exp"].(float64); ok { 40 | if int64(exp) < jwt.TimeFunc().Unix() { 41 | c.JSON(http.StatusUnauthorized, gin.H{"error": "Token expired"}) 42 | c.Abort() 43 | return 44 | } 45 | } else { 46 | c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"}) 47 | c.Abort() 48 | return 49 | } 50 | 51 | if t, ok := claims["type"].(string); ok { 52 | if t != "access" { 53 | c.JSON(http.StatusForbidden, gin.H{"error": "Token type mismatch"}) 54 | c.Abort() 55 | return 56 | } 57 | } else { 58 | c.JSON(http.StatusForbidden, gin.H{"error": "Missing token type"}) 59 | c.Abort() 60 | return 61 | } 62 | 63 | c.Next() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/infrastructure/rest/middlewares/RequiresLogin_test.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/golang-jwt/jwt/v4" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func setupGinContext() (*gin.Context, *httptest.ResponseRecorder) { 17 | gin.SetMode(gin.TestMode) 18 | w := httptest.NewRecorder() 19 | c, _ := gin.CreateTestContext(w) 20 | return c, w 21 | } 22 | 23 | func TestAuthJWTMiddleware_NoToken(t *testing.T) { 24 | c, w := setupGinContext() 25 | c.Request = httptest.NewRequest("GET", "/protected", nil) 26 | 27 | middleware := AuthJWTMiddleware() 28 | middleware(c) 29 | 30 | assert.Equal(t, http.StatusUnauthorized, w.Code) 31 | 32 | var response map[string]interface{} 33 | err := json.Unmarshal(w.Body.Bytes(), &response) 34 | assert.NoError(t, err) 35 | assert.Equal(t, "Token not provided", response["error"]) 36 | } 37 | 38 | func TestAuthJWTMiddleware_NoJWTSecret(t *testing.T) { 39 | // Clear JWT_ACCESS_SECRET_KEY 40 | originalSecret := os.Getenv("JWT_ACCESS_SECRET_KEY") 41 | os.Unsetenv("JWT_ACCESS_SECRET_KEY") 42 | defer os.Setenv("JWT_ACCESS_SECRET_KEY", originalSecret) 43 | 44 | c, w := setupGinContext() 45 | c.Request = httptest.NewRequest("GET", "/protected", nil) 46 | c.Request.Header.Set("Authorization", "Bearer valid-token") 47 | 48 | middleware := AuthJWTMiddleware() 49 | middleware(c) 50 | 51 | assert.Equal(t, http.StatusUnauthorized, w.Code) 52 | 53 | var response map[string]interface{} 54 | err := json.Unmarshal(w.Body.Bytes(), &response) 55 | assert.NoError(t, err) 56 | assert.Equal(t, "JWT_ACCESS_SECRET_KEY not configured", response["error"]) 57 | } 58 | 59 | func TestAuthJWTMiddleware_InvalidToken(t *testing.T) { 60 | // Set JWT_ACCESS_SECRET_KEY 61 | originalSecret := os.Getenv("JWT_ACCESS_SECRET_KEY") 62 | os.Setenv("JWT_ACCESS_SECRET_KEY", "test-secret") 63 | defer os.Setenv("JWT_ACCESS_SECRET_KEY", originalSecret) 64 | 65 | c, w := setupGinContext() 66 | c.Request = httptest.NewRequest("GET", "/protected", nil) 67 | c.Request.Header.Set("Authorization", "Bearer invalid-token") 68 | 69 | middleware := AuthJWTMiddleware() 70 | middleware(c) 71 | 72 | assert.Equal(t, http.StatusUnauthorized, w.Code) 73 | 74 | var response map[string]interface{} 75 | err := json.Unmarshal(w.Body.Bytes(), &response) 76 | assert.NoError(t, err) 77 | assert.Equal(t, "Invalid token", response["error"]) 78 | } 79 | 80 | func TestAuthJWTMiddleware_ExpiredToken(t *testing.T) { 81 | // Set JWT_ACCESS_SECRET_KEY 82 | originalSecret := os.Getenv("JWT_ACCESS_SECRET_KEY") 83 | os.Setenv("JWT_ACCESS_SECRET_KEY", "test-secret") 84 | defer os.Setenv("JWT_ACCESS_SECRET_KEY", originalSecret) 85 | 86 | // Create expired token 87 | claims := jwt.MapClaims{ 88 | "exp": time.Now().Add(-1 * time.Hour).Unix(), // Expired 1 hour ago 89 | "type": "access", 90 | } 91 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 92 | tokenString, _ := token.SignedString([]byte("test-secret")) 93 | 94 | c, w := setupGinContext() 95 | c.Request = httptest.NewRequest("GET", "/protected", nil) 96 | c.Request.Header.Set("Authorization", "Bearer "+tokenString) 97 | 98 | middleware := AuthJWTMiddleware() 99 | middleware(c) 100 | 101 | assert.Equal(t, http.StatusUnauthorized, w.Code) 102 | 103 | var response map[string]interface{} 104 | err := json.Unmarshal(w.Body.Bytes(), &response) 105 | assert.NoError(t, err) 106 | // The error message might be "Invalid token" instead of "Token expired" due to JWT parsing 107 | assert.Contains(t, []string{"Token expired", "Invalid token"}, response["error"]) 108 | } 109 | 110 | func TestAuthJWTMiddleware_InvalidTokenClaims(t *testing.T) { 111 | // Set JWT_ACCESS_SECRET_KEY 112 | originalSecret := os.Getenv("JWT_ACCESS_SECRET_KEY") 113 | os.Setenv("JWT_ACCESS_SECRET_KEY", "test-secret") 114 | defer os.Setenv("JWT_ACCESS_SECRET_KEY", originalSecret) 115 | 116 | // Create token without exp claim 117 | claims := jwt.MapClaims{ 118 | "type": "access", 119 | } 120 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 121 | tokenString, _ := token.SignedString([]byte("test-secret")) 122 | 123 | c, w := setupGinContext() 124 | c.Request = httptest.NewRequest("GET", "/protected", nil) 125 | c.Request.Header.Set("Authorization", "Bearer "+tokenString) 126 | 127 | middleware := AuthJWTMiddleware() 128 | middleware(c) 129 | 130 | assert.Equal(t, http.StatusUnauthorized, w.Code) 131 | 132 | var response map[string]interface{} 133 | err := json.Unmarshal(w.Body.Bytes(), &response) 134 | assert.NoError(t, err) 135 | assert.Equal(t, "Invalid token claims", response["error"]) 136 | } 137 | 138 | func TestAuthJWTMiddleware_WrongTokenType(t *testing.T) { 139 | // Set JWT_ACCESS_SECRET_KEY 140 | originalSecret := os.Getenv("JWT_ACCESS_SECRET_KEY") 141 | os.Setenv("JWT_ACCESS_SECRET_KEY", "test-secret") 142 | defer os.Setenv("JWT_ACCESS_SECRET_KEY", originalSecret) 143 | 144 | // Create token with wrong type 145 | claims := jwt.MapClaims{ 146 | "exp": time.Now().Add(1 * time.Hour).Unix(), 147 | "type": "refresh", // Wrong type 148 | } 149 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 150 | tokenString, _ := token.SignedString([]byte("test-secret")) 151 | 152 | c, w := setupGinContext() 153 | c.Request = httptest.NewRequest("GET", "/protected", nil) 154 | c.Request.Header.Set("Authorization", "Bearer "+tokenString) 155 | 156 | middleware := AuthJWTMiddleware() 157 | middleware(c) 158 | 159 | assert.Equal(t, http.StatusForbidden, w.Code) 160 | 161 | var response map[string]interface{} 162 | err := json.Unmarshal(w.Body.Bytes(), &response) 163 | assert.NoError(t, err) 164 | assert.Equal(t, "Token type mismatch", response["error"]) 165 | } 166 | 167 | func TestAuthJWTMiddleware_MissingTokenType(t *testing.T) { 168 | // Set JWT_ACCESS_SECRET_KEY 169 | originalSecret := os.Getenv("JWT_ACCESS_SECRET_KEY") 170 | os.Setenv("JWT_ACCESS_SECRET_KEY", "test-secret") 171 | defer os.Setenv("JWT_ACCESS_SECRET_KEY", originalSecret) 172 | 173 | // Create token without type claim 174 | claims := jwt.MapClaims{ 175 | "exp": time.Now().Add(1 * time.Hour).Unix(), 176 | } 177 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 178 | tokenString, _ := token.SignedString([]byte("test-secret")) 179 | 180 | c, w := setupGinContext() 181 | c.Request = httptest.NewRequest("GET", "/protected", nil) 182 | c.Request.Header.Set("Authorization", "Bearer "+tokenString) 183 | 184 | middleware := AuthJWTMiddleware() 185 | middleware(c) 186 | 187 | assert.Equal(t, http.StatusForbidden, w.Code) 188 | 189 | var response map[string]interface{} 190 | err := json.Unmarshal(w.Body.Bytes(), &response) 191 | assert.NoError(t, err) 192 | assert.Equal(t, "Missing token type", response["error"]) 193 | } 194 | 195 | func TestAuthJWTMiddleware_ValidToken(t *testing.T) { 196 | // Set JWT_ACCESS_SECRET_KEY 197 | originalSecret := os.Getenv("JWT_ACCESS_SECRET_KEY") 198 | os.Setenv("JWT_ACCESS_SECRET_KEY", "test-secret") 199 | defer os.Setenv("JWT_ACCESS_SECRET_KEY", originalSecret) 200 | 201 | // Create valid token 202 | claims := jwt.MapClaims{ 203 | "exp": time.Now().Add(1 * time.Hour).Unix(), 204 | "type": "access", 205 | "id": 123, 206 | } 207 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 208 | tokenString, _ := token.SignedString([]byte("test-secret")) 209 | 210 | c, w := setupGinContext() 211 | c.Request = httptest.NewRequest("GET", "/protected", nil) 212 | c.Request.Header.Set("Authorization", "Bearer "+tokenString) 213 | 214 | middleware := AuthJWTMiddleware() 215 | middleware(c) 216 | 217 | assert.Equal(t, http.StatusOK, w.Code) 218 | } 219 | 220 | func TestAuthJWTMiddleware_TokenWithoutBearer(t *testing.T) { 221 | // Set JWT_ACCESS_SECRET_KEY 222 | originalSecret := os.Getenv("JWT_ACCESS_SECRET_KEY") 223 | os.Setenv("JWT_ACCESS_SECRET_KEY", "test-secret") 224 | defer os.Setenv("JWT_ACCESS_SECRET_KEY", originalSecret) 225 | 226 | // Create valid token 227 | claims := jwt.MapClaims{ 228 | "exp": time.Now().Add(1 * time.Hour).Unix(), 229 | "type": "access", 230 | "id": 123, 231 | } 232 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 233 | tokenString, _ := token.SignedString([]byte("test-secret")) 234 | 235 | c, w := setupGinContext() 236 | c.Request = httptest.NewRequest("GET", "/protected", nil) 237 | c.Request.Header.Set("Authorization", tokenString) // Without "Bearer " prefix 238 | 239 | middleware := AuthJWTMiddleware() 240 | middleware(c) 241 | 242 | // The middleware should still process the token even without "Bearer " prefix 243 | // because strings.TrimPrefix handles this case 244 | assert.Equal(t, http.StatusOK, w.Code) 245 | } 246 | -------------------------------------------------------------------------------- /src/infrastructure/rest/middlewares/errorHandler.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | domainErrors "github.com/gbrayhan/microservices-go/src/domain/errors" 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func ErrorHandler() gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | c.Next() 14 | 15 | if len(c.Errors) > 0 { 16 | err := c.Errors.Last().Err 17 | var appErr *domainErrors.AppError 18 | if errors.As(err, &appErr) { 19 | status, message := domainErrors.AppErrorToHTTP(appErr) 20 | c.JSON(status, gin.H{"error": message}) 21 | } else { 22 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"}) 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/infrastructure/rest/middlewares/errorHandler_test.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | domainErrors "github.com/gbrayhan/microservices-go/src/domain/errors" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func TestErrorHandler_NoErrors(t *testing.T) { 14 | // Set Gin to test mode 15 | gin.SetMode(gin.TestMode) 16 | 17 | // Create a new Gin router 18 | router := gin.New() 19 | router.Use(ErrorHandler()) 20 | 21 | // Add a test route 22 | router.GET("/test", func(c *gin.Context) { 23 | c.JSON(http.StatusOK, gin.H{"message": "success"}) 24 | }) 25 | 26 | // Create a test request 27 | w := httptest.NewRecorder() 28 | req, _ := http.NewRequest("GET", "/test", nil) 29 | 30 | // Serve the request 31 | router.ServeHTTP(w, req) 32 | 33 | // Check response status 34 | if w.Code != http.StatusOK { 35 | t.Errorf("Expected status 200, got %d", w.Code) 36 | } 37 | } 38 | 39 | func TestErrorHandler_NotFoundError(t *testing.T) { 40 | // Set Gin to test mode 41 | gin.SetMode(gin.TestMode) 42 | 43 | // Create a new Gin router 44 | router := gin.New() 45 | router.Use(ErrorHandler()) 46 | 47 | // Add a test route that generates a NotFound error 48 | router.GET("/test", func(c *gin.Context) { 49 | appErr := domainErrors.NewAppErrorWithType(domainErrors.NotFound) 50 | _ = c.Error(appErr) 51 | }) 52 | 53 | // Create a test request 54 | w := httptest.NewRecorder() 55 | req, _ := http.NewRequest("GET", "/test", nil) 56 | 57 | // Serve the request 58 | router.ServeHTTP(w, req) 59 | 60 | // Check response status 61 | if w.Code != http.StatusNotFound { 62 | t.Errorf("Expected status 404, got %d", w.Code) 63 | } 64 | 65 | // Check response body 66 | expectedBody := `{"error":"record not found"}` 67 | if w.Body.String() != expectedBody { 68 | t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String()) 69 | } 70 | } 71 | 72 | func TestErrorHandler_ValidationError(t *testing.T) { 73 | // Set Gin to test mode 74 | gin.SetMode(gin.TestMode) 75 | 76 | // Create a new Gin router 77 | router := gin.New() 78 | router.Use(ErrorHandler()) 79 | 80 | // Add a test route that generates a ValidationError 81 | router.GET("/test", func(c *gin.Context) { 82 | appErr := domainErrors.NewAppErrorWithType(domainErrors.ValidationError) 83 | _ = c.Error(appErr) 84 | }) 85 | 86 | // Create a test request 87 | w := httptest.NewRecorder() 88 | req, _ := http.NewRequest("GET", "/test", nil) 89 | 90 | // Serve the request 91 | router.ServeHTTP(w, req) 92 | 93 | // Check response status 94 | if w.Code != http.StatusBadRequest { 95 | t.Errorf("Expected status 400, got %d", w.Code) 96 | } 97 | 98 | // Check response body 99 | expectedBody := `{"error":"validation error"}` 100 | if w.Body.String() != expectedBody { 101 | t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String()) 102 | } 103 | } 104 | 105 | func TestErrorHandler_RepositoryError(t *testing.T) { 106 | // Set Gin to test mode 107 | gin.SetMode(gin.TestMode) 108 | 109 | // Create a new Gin router 110 | router := gin.New() 111 | router.Use(ErrorHandler()) 112 | 113 | // Add a test route that generates a RepositoryError 114 | router.GET("/test", func(c *gin.Context) { 115 | appErr := domainErrors.NewAppErrorWithType(domainErrors.RepositoryError) 116 | _ = c.Error(appErr) 117 | }) 118 | 119 | // Create a test request 120 | w := httptest.NewRecorder() 121 | req, _ := http.NewRequest("GET", "/test", nil) 122 | 123 | // Serve the request 124 | router.ServeHTTP(w, req) 125 | 126 | // Check response status 127 | if w.Code != http.StatusInternalServerError { 128 | t.Errorf("Expected status 500, got %d", w.Code) 129 | } 130 | 131 | // Check response body 132 | expectedBody := `{"error":"error in repository operation"}` 133 | if w.Body.String() != expectedBody { 134 | t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String()) 135 | } 136 | } 137 | 138 | func TestErrorHandler_NotAuthenticatedError(t *testing.T) { 139 | // Set Gin to test mode 140 | gin.SetMode(gin.TestMode) 141 | 142 | // Create a new Gin router 143 | router := gin.New() 144 | router.Use(ErrorHandler()) 145 | 146 | // Add a test route that generates a NotAuthenticated error 147 | router.GET("/test", func(c *gin.Context) { 148 | appErr := domainErrors.NewAppErrorWithType(domainErrors.NotAuthenticated) 149 | _ = c.Error(appErr) 150 | }) 151 | 152 | // Create a test request 153 | w := httptest.NewRecorder() 154 | req, _ := http.NewRequest("GET", "/test", nil) 155 | 156 | // Serve the request 157 | router.ServeHTTP(w, req) 158 | 159 | // Check response status 160 | if w.Code != http.StatusUnauthorized { 161 | t.Errorf("Expected status 401, got %d", w.Code) 162 | } 163 | 164 | // Check response body 165 | expectedBody := `{"error":"not Authenticated"}` 166 | if w.Body.String() != expectedBody { 167 | t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String()) 168 | } 169 | } 170 | 171 | func TestErrorHandler_NotAuthorizedError(t *testing.T) { 172 | // Set Gin to test mode 173 | gin.SetMode(gin.TestMode) 174 | 175 | // Create a new Gin router 176 | router := gin.New() 177 | router.Use(ErrorHandler()) 178 | 179 | // Add a test route that generates a NotAuthorized error 180 | router.GET("/test", func(c *gin.Context) { 181 | appErr := domainErrors.NewAppErrorWithType(domainErrors.NotAuthorized) 182 | _ = c.Error(appErr) 183 | }) 184 | 185 | // Create a test request 186 | w := httptest.NewRecorder() 187 | req, _ := http.NewRequest("GET", "/test", nil) 188 | 189 | // Serve the request 190 | router.ServeHTTP(w, req) 191 | 192 | // Check response status 193 | if w.Code != http.StatusForbidden { 194 | t.Errorf("Expected status 403, got %d", w.Code) 195 | } 196 | 197 | // Check response body 198 | expectedBody := `{"error":"not authorized"}` 199 | if w.Body.String() != expectedBody { 200 | t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String()) 201 | } 202 | } 203 | 204 | func TestErrorHandler_UnknownErrorType(t *testing.T) { 205 | // Set Gin to test mode 206 | gin.SetMode(gin.TestMode) 207 | 208 | // Create a new Gin router 209 | router := gin.New() 210 | router.Use(ErrorHandler()) 211 | 212 | // Add a test route that generates an unknown error type 213 | router.GET("/test", func(c *gin.Context) { 214 | appErr := domainErrors.NewAppErrorWithType("UnknownErrorType") 215 | _ = c.Error(appErr) 216 | }) 217 | 218 | // Create a test request 219 | w := httptest.NewRecorder() 220 | req, _ := http.NewRequest("GET", "/test", nil) 221 | 222 | // Serve the request 223 | router.ServeHTTP(w, req) 224 | 225 | // Check response status 226 | if w.Code != http.StatusInternalServerError { 227 | t.Errorf("Expected status 500, got %d", w.Code) 228 | } 229 | 230 | // Check response body 231 | expectedBody := `{"error":"Internal Server Error"}` 232 | if w.Body.String() != expectedBody { 233 | t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String()) 234 | } 235 | } 236 | 237 | func TestErrorHandler_NonAppError(t *testing.T) { 238 | // Set Gin to test mode 239 | gin.SetMode(gin.TestMode) 240 | 241 | // Create a new Gin router 242 | router := gin.New() 243 | router.Use(ErrorHandler()) 244 | 245 | // Add a test route that generates a regular error 246 | router.GET("/test", func(c *gin.Context) { 247 | _ = c.Error(errors.New("regular error")) 248 | }) 249 | 250 | // Create a test request 251 | w := httptest.NewRecorder() 252 | req, _ := http.NewRequest("GET", "/test", nil) 253 | 254 | // Serve the request 255 | router.ServeHTTP(w, req) 256 | 257 | // Check response status 258 | if w.Code != http.StatusInternalServerError { 259 | t.Errorf("Expected status 500, got %d", w.Code) 260 | } 261 | 262 | // Check response body 263 | expectedBody := `{"error":"Internal Server Error"}` 264 | if w.Body.String() != expectedBody { 265 | t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String()) 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/infrastructure/rest/routes/auth.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | authController "github.com/gbrayhan/microservices-go/src/infrastructure/rest/controllers/auth" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | func AuthRoutes(router *gin.RouterGroup, controller authController.IAuthController) { 9 | routerAuth := router.Group("/auth") 10 | { 11 | routerAuth.POST("/login", controller.Login) 12 | routerAuth.POST("/access-token", controller.GetAccessTokenByRefreshToken) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/infrastructure/rest/routes/medicine.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gbrayhan/microservices-go/src/infrastructure/rest/controllers/medicine" 5 | "github.com/gbrayhan/microservices-go/src/infrastructure/rest/middlewares" 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func MedicineRoutes(router *gin.RouterGroup, controller medicine.IMedicineController) { 10 | med := router.Group("/medicine") 11 | med.Use(middlewares.AuthJWTMiddleware()) 12 | { 13 | med.GET("/", controller.GetAllMedicines) 14 | med.POST("/", controller.NewMedicine) 15 | med.GET("/:id", controller.GetMedicinesByID) 16 | med.PUT("/:id", controller.UpdateMedicine) 17 | med.DELETE("/:id", controller.DeleteMedicine) 18 | med.GET("/search", controller.SearchPaginated) 19 | med.GET("/search-property", controller.SearchByProperty) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/infrastructure/rest/routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gbrayhan/microservices-go/src/infrastructure/di" 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | func ApplicationRouter(router *gin.Engine, appContext *di.ApplicationContext) { 11 | v1 := router.Group("/v1") 12 | 13 | v1.GET("/health", func(c *gin.Context) { 14 | c.JSON(http.StatusOK, gin.H{ 15 | "status": "ok", 16 | "message": "Service is running", 17 | }) 18 | }) 19 | 20 | AuthRoutes(v1, appContext.AuthController) 21 | UserRoutes(v1, appContext.UserController) 22 | MedicineRoutes(v1, appContext.MedicineController) 23 | } 24 | -------------------------------------------------------------------------------- /src/infrastructure/rest/routes/user.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gbrayhan/microservices-go/src/infrastructure/rest/controllers/user" 5 | "github.com/gbrayhan/microservices-go/src/infrastructure/rest/middlewares" 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func UserRoutes(router *gin.RouterGroup, controller user.IUserController) { 10 | u := router.Group("/user") 11 | u.Use(middlewares.AuthJWTMiddleware()) 12 | { 13 | u.POST("/", controller.NewUser) 14 | u.GET("/", controller.GetAllUsers) 15 | u.GET("/:id", controller.GetUsersByID) 16 | u.PUT("/:id", controller.UpdateUser) 17 | u.DELETE("/:id", controller.DeleteUser) 18 | u.GET("/search", controller.SearchPaginated) 19 | u.GET("/search-property", controller.SearchByProperty) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/infrastructure/security/jwt_service.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "time" 9 | 10 | domainErrors "github.com/gbrayhan/microservices-go/src/domain/errors" 11 | "github.com/golang-jwt/jwt/v4" 12 | ) 13 | 14 | const ( 15 | Access = "access" 16 | Refresh = "refresh" 17 | ) 18 | 19 | type AppToken struct { 20 | Token string `json:"token"` 21 | TokenType string `json:"type"` 22 | ExpirationTime time.Time `json:"expirationTime"` 23 | } 24 | 25 | type Claims struct { 26 | ID int `json:"id"` 27 | Type string `json:"type"` 28 | jwt.RegisteredClaims 29 | } 30 | 31 | // JWTConfig holds JWT-related configuration 32 | type JWTConfig struct { 33 | AccessSecret string 34 | RefreshSecret string 35 | AccessTime int64 36 | RefreshTime int64 37 | } 38 | 39 | // IJWTService defines the interface for JWT operations 40 | type IJWTService interface { 41 | GenerateJWTToken(userID int, tokenType string) (*AppToken, error) 42 | GetClaimsAndVerifyToken(tokenString string, tokenType string) (jwt.MapClaims, error) 43 | } 44 | 45 | // JWTService implements IJWTService 46 | type JWTService struct { 47 | config JWTConfig 48 | } 49 | 50 | // NewJWTService creates a new JWT service instance 51 | func NewJWTService() IJWTService { 52 | config := loadJWTConfig() 53 | return &JWTService{ 54 | config: config, 55 | } 56 | } 57 | 58 | // NewJWTServiceWithConfig creates a new JWT service with custom configuration 59 | func NewJWTServiceWithConfig(config JWTConfig) IJWTService { 60 | return &JWTService{ 61 | config: config, 62 | } 63 | } 64 | 65 | // loadJWTConfig loads JWT configuration from environment variables 66 | func loadJWTConfig() JWTConfig { 67 | return JWTConfig{ 68 | AccessSecret: getEnvOrDefault("JWT_ACCESS_SECRET_KEY", "default_access_secret"), 69 | RefreshSecret: getEnvOrDefault("JWT_REFRESH_SECRET_KEY", "default_refresh_secret"), 70 | AccessTime: getEnvAsInt64OrDefault("JWT_ACCESS_TIME_MINUTE", 60), 71 | RefreshTime: getEnvAsInt64OrDefault("JWT_REFRESH_TIME_HOUR", 24), 72 | } 73 | } 74 | 75 | // GenerateJWTToken generates a JWT token for the given user ID and type 76 | func (s *JWTService) GenerateJWTToken(userID int, tokenType string) (*AppToken, error) { 77 | var secretKey string 78 | var duration time.Duration 79 | 80 | switch tokenType { 81 | case Access: 82 | secretKey = s.config.AccessSecret 83 | duration = time.Duration(s.config.AccessTime) * time.Minute 84 | case Refresh: 85 | secretKey = s.config.RefreshSecret 86 | duration = time.Duration(s.config.RefreshTime) * time.Hour 87 | default: 88 | return nil, errors.New("invalid token type") 89 | } 90 | 91 | nowTime := time.Now() 92 | expirationTokenTime := nowTime.Add(duration) 93 | 94 | tokenClaims := &Claims{ 95 | ID: userID, 96 | Type: tokenType, 97 | RegisteredClaims: jwt.RegisteredClaims{ 98 | ExpiresAt: jwt.NewNumericDate(expirationTokenTime), 99 | }, 100 | } 101 | tokenWithClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, tokenClaims) 102 | 103 | tokenStr, err := tokenWithClaims.SignedString([]byte(secretKey)) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | return &AppToken{ 109 | Token: tokenStr, 110 | TokenType: tokenType, 111 | ExpirationTime: expirationTokenTime, 112 | }, nil 113 | } 114 | 115 | // GetClaimsAndVerifyToken verifies a JWT token and returns its claims 116 | func (s *JWTService) GetClaimsAndVerifyToken(tokenString string, tokenType string) (jwt.MapClaims, error) { 117 | var secretKey string 118 | 119 | if tokenType == Refresh { 120 | secretKey = s.config.RefreshSecret 121 | } else { 122 | secretKey = s.config.AccessSecret 123 | } 124 | 125 | token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { 126 | if token.Method.Alg() != jwt.SigningMethodHS256.Alg() { 127 | return nil, domainErrors.NewAppError( 128 | fmt.Errorf("unexpected signing method: %v", token.Header["alg"]), 129 | domainErrors.NotAuthenticated, 130 | ) 131 | } 132 | return []byte(secretKey), nil 133 | }) 134 | 135 | if err != nil { 136 | return nil, domainErrors.NewAppError(err, domainErrors.NotAuthenticated) 137 | } 138 | 139 | claims, ok := token.Claims.(jwt.MapClaims) 140 | if !ok || !token.Valid { 141 | return nil, domainErrors.NewAppError(errors.New("invalid claims type or token not valid"), domainErrors.NotAuthenticated) 142 | } 143 | 144 | if claims["type"] != tokenType { 145 | return nil, domainErrors.NewAppError(errors.New("invalid token type"), domainErrors.NotAuthenticated) 146 | } 147 | 148 | expVal, ok := claims["exp"] 149 | if !ok || expVal == nil { 150 | return nil, domainErrors.NewAppError(errors.New("token missing expiration (exp) claim"), domainErrors.NotAuthenticated) 151 | } 152 | timeExpire, ok := expVal.(float64) 153 | if !ok { 154 | return nil, domainErrors.NewAppError(errors.New("token expiration (exp) claim is not a float64"), domainErrors.NotAuthenticated) 155 | } 156 | if time.Now().Unix() > int64(timeExpire) { 157 | return nil, domainErrors.NewAppError(errors.New("token expired"), domainErrors.NotAuthenticated) 158 | } 159 | 160 | idVal, ok := claims["id"] 161 | if !ok || idVal == nil { 162 | return nil, domainErrors.NewAppError(errors.New("token missing id claim"), domainErrors.NotAuthenticated) 163 | } 164 | // Accept float64 or int64 for id 165 | switch idVal.(type) { 166 | case float64, int64, int: 167 | // ok 168 | default: 169 | return nil, domainErrors.NewAppError(errors.New("token id claim is not a number"), domainErrors.NotAuthenticated) 170 | } 171 | 172 | return claims, nil 173 | } 174 | 175 | // Helper functions 176 | func getEnvOrDefault(key, defaultValue string) string { 177 | if value := os.Getenv(key); value != "" { 178 | return value 179 | } 180 | return defaultValue 181 | } 182 | 183 | func getEnvAsInt64OrDefault(key string, defaultValue int64) int64 { 184 | if value := os.Getenv(key); value != "" { 185 | if intValue, err := strconv.ParseInt(value, 10, 64); err == nil { 186 | return intValue 187 | } 188 | } 189 | return defaultValue 190 | } 191 | --------------------------------------------------------------------------------