Merge pull request #10 from JurunenseDesenvolvimento/homologacao

Homologacao
This commit is contained in:
Joelson
2025-11-14 17:39:04 -03:00
committed by GitHub
146 changed files with 7073 additions and 3896 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
node_modules
npm-debug.log
dist
.git
.gitignore
.env
.env.*
!.env.example
coverage
.nyc_output
*.log
*.md
.vscode
.idea
*.swp
*.swo
*~
.DS_Store
test
*.spec.ts
*.spec.js
__tests__
jest.config.js
jest.setup.js
.eslintrc.js
eslint.config.js
.prettierrc
.prettierignore
monitoring
docs

254
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,254 @@
name: CI
on:
push:
branches: [ main, master, develop, homologacao ]
tags:
- 'v*'
pull_request:
branches: [ main, master, develop, homologacao ]
jobs:
lint:
name: Lint and Format Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npx eslint "src/**/*.ts" --max-warnings 0
continue-on-error: true
- name: Run Prettier check
run: npx prettier --check "src/**/*.ts" "test/**/*.ts"
continue-on-error: true
build:
name: Build
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 1
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test
- name: Generate coverage report
run: npm run test:cov
continue-on-error: true
- name: Upload coverage reports
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
retention-days: 7
test-e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Run e2e tests
run: npm run test:e2e
env:
NODE_ENV: test
continue-on-error: true
docker-build:
name: Docker Build
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push'
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get package version
id: package-version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Package version: $VERSION"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-
type=raw,value=${{ steps.package-version.outputs.version }},enable=${{ github.ref_type == 'branch' }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
release:
name: Create Release
runs-on: ubuntu-latest
needs: [build, docker-build]
if: startsWith(github.ref, 'refs/tags/v') && github.ref_type == 'tag'
permissions:
contents: write
env:
REGISTRY: ghcr.io
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Check if tag is on main branch
id: check-branch
run: |
git fetch origin main:main 2>/dev/null || true
if git branch -r --contains ${{ github.ref_name }} | grep -q 'origin/main'; then
echo "on_main=true" >> $GITHUB_OUTPUT
echo "Tag is on main branch"
else
echo "on_main=false" >> $GITHUB_OUTPUT
echo "Tag is not on main branch, skipping release"
fi
- name: Get version from tag
id: version
run: |
TAG_VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
echo "Tag version: $TAG_VERSION"
- name: Generate changelog
id: changelog
run: |
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s (%h)" HEAD)
else
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${PREVIOUS_TAG}..HEAD)
fi
echo "changelog<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
if: steps.check-branch.outputs.on_main == 'true'
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ steps.version.outputs.version }}
body: |
## Versão ${{ steps.version.outputs.version }}
### Mudanças
${{ steps.changelog.outputs.changelog }}
### Docker Image
```bash
docker pull ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.version.outputs.version }}
docker pull ${{ env.REGISTRY }}/${{ github.repository }}:latest
```
draft: false
prerelease: false

61
Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
FROM oraclelinux:9 AS base
WORKDIR /app
RUN dnf install -y \
curl \
libaio \
&& curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - \
&& dnf install -y nodejs \
&& dnf clean all
FROM base AS dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
FROM base AS build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM base AS production
RUN dnf install -y \
curl \
libaio \
&& dnf clean all
ENV ORACLE_CLIENT_LIB_DIR=/usr/lib/oracle/21/client64/lib
RUN curl -fSL --cookie-jar /tmp/cookies.txt --retry 3 \
"https://download.oracle.com/otn_software/linux/instantclient/2112000/el9/oracle-instantclient-basiclite-21.12.0.0.0-1.el9.x86_64.rpm" \
--output /tmp/oracle-instantclient-basiclite.rpm && \
dnf install -y /tmp/oracle-instantclient-basiclite.rpm && \
rm -f /tmp/oracle-instantclient-basiclite.rpm /tmp/cookies.txt && \
dnf clean all
ENV LD_LIBRARY_PATH=/usr/lib/oracle/21/client64/lib
ENV TZ=America/Sao_Paulo
RUN groupadd -r node && useradd -r -g node node
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
COPY --from=build /app/cert ./cert
RUN chown -R node:node /app
EXPOSE 8066
USER node
CMD ["node", "dist/main"]

127
docs/VERSIONAMENTO.md Normal file
View File

@@ -0,0 +1,127 @@
# Sistema de Versionamento
Este documento descreve o sistema de versionamento utilizado no projeto, incluindo como as imagens Docker são versionadas e como criar releases no GitHub.
## Visão Geral
O projeto utiliza versionamento semântico baseado no arquivo `package.json` e tags Git para gerenciar versões de imagens Docker e releases no GitHub Container Registry (GHCR).
## Versionamento de Imagens Docker
As imagens Docker são automaticamente versionadas durante o processo de CI/CD baseado no contexto do push:
### Branches
- **Branch `homologacao`**: `ghcr.io/usuario/repo:homologacao`
- **Branch `main`**:
- `ghcr.io/usuario/repo:main`
- `ghcr.io/usuario/repo:latest`
- `ghcr.io/usuario/repo:{versao-do-package.json}`
### Tags Git
Quando uma tag é criada (formato `v*`, exemplo: `v0.1.0`), as seguintes tags são geradas:
- `ghcr.io/usuario/repo:0.1.0` (versão completa)
- `ghcr.io/usuario/repo:0.1` (major.minor)
- `ghcr.io/usuario/repo:0` (major)
### Commits
Cada commit gera uma tag com o SHA do commit:
- `ghcr.io/usuario/repo:{branch}-{sha}`
## Releases no GitHub
Releases são criadas automaticamente quando uma tag Git no formato `v*` é enviada para a branch `main`. O processo inclui:
1. Verificação se a tag está na branch `main`
2. Geração automática de changelog baseado nos commits desde a última tag
3. Criação da release no GitHub com:
- Nome da versão
- Changelog completo
- Instruções para download da imagem Docker
## Como Criar uma Nova Versão
### Para Homologação
1. Atualize a versão no `package.json`:
```bash
npm version patch # Incrementa patch: 0.0.1 -> 0.0.2
npm version minor # Incrementa minor: 0.0.1 -> 0.1.0
npm version major # Incrementa major: 0.0.1 -> 1.0.0
```
2. Faça commit e push:
```bash
git add package.json package-lock.json
git commit -m "chore: bump version to 0.1.0"
git push origin homologacao
```
A imagem Docker será automaticamente buildada e publicada com a tag correspondente à branch.
### Para Produção (Release)
1. Faça merge da branch `homologacao` para `main`:
```bash
git checkout main
git merge homologacao
```
2. Crie uma tag e faça push:
```bash
git tag v0.1.0
git push origin main --tags
```
Este processo irá:
- Buildar a imagem Docker com as tags de versão semântica
- Criar automaticamente uma release no GitHub
- Publicar a imagem no GitHub Container Registry
## Verificar Versão Atual
A versão atual do projeto está definida no arquivo `package.json`:
```json
{
"version": "0.0.1"
}
```
## Estrutura de Tags Docker
As imagens Docker seguem o seguinte padrão de nomenclatura:
- **Desenvolvimento**: `{registry}/{repo}:{branch-name}`
- **Versão específica**: `{registry}/{repo}:{version}` (ex: `0.1.0`)
- **Versão parcial**: `{registry}/{repo}:{major}.{minor}` (ex: `0.1`)
- **Major version**: `{registry}/{repo}:{major}` (ex: `0`)
- **Latest**: `{registry}/{repo}:latest` (apenas branch main)
- **Commit SHA**: `{registry}/{repo}:{branch}-{sha}`
## Workflow de CI/CD
O workflow de CI/CD está configurado no arquivo `.github/workflows/ci.yml` e executa os seguintes jobs:
1. **Lint**: Verificação de código e formatação
2. **Build**: Compilação do projeto TypeScript
3. **Test**: Execução de testes unitários
4. **Test E2E**: Execução de testes end-to-end (apenas PRs e main)
5. **Docker Build**: Build e push da imagem Docker
6. **Release**: Criação de release no GitHub (apenas tags na main)
## Notas Importantes
- Tags devem seguir o formato semântico: `v{major}.{minor}.{patch}` (ex: `v1.0.0`)
- Releases são criadas apenas para tags na branch `main`
- A versão no `package.json` é usada para versionar imagens em branches
- Tags Git são usadas para versionar imagens em releases de produção

39
eslint.config.js Normal file
View File

@@ -0,0 +1,39 @@
const js = require('@eslint/js');
const parser = require('@typescript-eslint/parser');
const plugin = require('@typescript-eslint/eslint-plugin');
const prettierPlugin = require('eslint-plugin-prettier');
const prettierConfig = require('eslint-config-prettier');
module.exports = [
js.configs.recommended,
prettierConfig,
{
files: ['**/*.ts'],
languageOptions: {
parser: parser,
parserOptions: {
project: './tsconfig.json',
sourceType: 'module',
},
globals: {
node: true,
jest: true,
},
},
plugins: {
'@typescript-eslint': plugin,
prettier: prettierPlugin,
},
rules: {
...plugin.configs.recommended.rules,
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'prettier/prettier': 'error',
},
},
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
},
];

13
jest.setup.js Normal file
View File

@@ -0,0 +1,13 @@
// Mock para resolver problema do TypeORM com node:url
// Este arquivo é executado antes de todos os testes
// Mock do módulo 'glob' do TypeORM que causa problemas
jest.mock('glob', () => {
const originalModule = jest.requireActual('glob');
return {
...originalModule,
glob: jest.fn(),
globSync: jest.fn(),
};
});

481
package-lock.json generated
View File

@@ -64,6 +64,9 @@
"@types/multer": "^1.4.12",
"@types/node": "^22.14.0",
"@types/supertest": "^2.0.10",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"eslint": "^9.39.1",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-prettier": "^3.1.4",
"jest": "^30.2.0",
@@ -812,7 +815,6 @@
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
},
@@ -832,7 +834,6 @@
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
@@ -846,7 +847,6 @@
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
@@ -857,7 +857,6 @@
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
@@ -873,7 +872,6 @@
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^0.17.0"
},
@@ -887,7 +885,6 @@
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -901,7 +898,6 @@
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
@@ -926,7 +922,6 @@
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -940,7 +935,6 @@
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
@@ -951,7 +945,6 @@
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^0.17.0",
"levn": "^0.4.1"
@@ -966,7 +959,6 @@
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18.0"
}
@@ -977,7 +969,6 @@
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
@@ -992,7 +983,6 @@
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=12.22"
},
@@ -1007,7 +997,6 @@
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18"
},
@@ -3217,6 +3206,44 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nuxt/opencollective": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz",
@@ -3694,6 +3721,264 @@
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz",
"integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/type-utils": "8.46.3",
"@typescript-eslint/utils": "8.46.3",
"@typescript-eslint/visitor-keys": "8.46.3",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.46.3",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz",
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
"@typescript-eslint/typescript-estree": "8.46.3",
"@typescript-eslint/visitor-keys": "8.46.3",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz",
"integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.46.3",
"@typescript-eslint/types": "^8.46.3",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz",
"integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.3",
"@typescript-eslint/visitor-keys": "8.46.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz",
"integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz",
"integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.3",
"@typescript-eslint/typescript-estree": "8.46.3",
"@typescript-eslint/utils": "8.46.3",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz",
"integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz",
"integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.46.3",
"@typescript-eslint/tsconfig-utils": "8.46.3",
"@typescript-eslint/types": "8.46.3",
"@typescript-eslint/visitor-keys": "8.46.3",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz",
"integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
"@typescript-eslint/typescript-estree": "8.46.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz",
"integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.3",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -4187,7 +4472,6 @@
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
@@ -5528,8 +5812,7 @@
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
@@ -5843,7 +6126,6 @@
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -5857,7 +6139,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5980,7 +6261,6 @@
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -5994,7 +6274,6 @@
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
@@ -6012,7 +6291,6 @@
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
@@ -6045,7 +6323,6 @@
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -6082,7 +6359,6 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6219,6 +6495,36 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -6231,8 +6537,7 @@
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
@@ -6257,6 +6562,16 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -6279,7 +6594,6 @@
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"flat-cache": "^4.0.0"
},
@@ -6341,7 +6655,6 @@
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -6359,7 +6672,6 @@
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
@@ -6373,8 +6685,7 @@
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
@@ -6717,7 +7028,6 @@
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@@ -6764,7 +7074,6 @@
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -6791,6 +7100,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true,
"license": "MIT"
},
"node_modules/guid-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
@@ -6961,7 +7277,6 @@
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
@@ -7120,7 +7435,6 @@
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7168,7 +7482,6 @@
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@@ -8947,8 +9260,7 @@
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
@@ -8969,8 +9281,7 @@
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
@@ -9054,7 +9365,6 @@
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"json-buffer": "3.0.1"
}
@@ -9075,7 +9385,6 @@
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
@@ -9132,7 +9441,6 @@
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -9209,8 +9517,7 @@
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
@@ -9364,6 +9671,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -9776,7 +10093,6 @@
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -9858,7 +10174,6 @@
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -10343,7 +10658,6 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8.0"
}
@@ -10523,6 +10837,27 @@
"node": ">=0.4.x"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -10699,6 +11034,17 @@
"dev": true,
"license": "ISC"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rimraf": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
@@ -10735,6 +11081,30 @@
"node": ">= 18"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -11741,6 +12111,19 @@
"tree-kill": "cli.js"
}
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/ts-jest": {
"version": "29.4.5",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz",
@@ -11974,7 +12357,6 @@
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
},
@@ -12792,7 +13174,6 @@
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}

View File

@@ -79,6 +79,9 @@
"@types/multer": "^1.4.12",
"@types/node": "^22.14.0",
"@types/supertest": "^2.0.10",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"eslint": "^9.39.1",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-prettier": "^3.1.4",
"jest": "^30.2.0",
@@ -109,6 +112,10 @@
"testEnvironment": "node",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
}
},
"transformIgnorePatterns": [
"node_modules/(?!(typeorm|@nestjs)/)"
],
"setupFilesAfterEnv": ["../jest.setup.js"]
}
}

View File

@@ -1,6 +0,0 @@
export interface ILogger {
log(message: string): void;
warn(message: string): void;
error(message: string, trace?: string): void;
}

View File

@@ -1,26 +0,0 @@
import { Logger } from '@nestjs/common';
import { ILogger } from './ILogger';
export class NestLoggerAdapter implements ILogger {
private readonly logger: Logger;
constructor(private readonly context: string) {
this.logger = new Logger(context);
}
log(message: string, meta?: Record<string, any>): void {
this.logger.log(this.formatMessage(message, meta));
}
warn(message: string, meta?: Record<string, any>): void {
this.logger.warn(this.formatMessage(message, meta));
}
error(message: string, trace?: string, meta?: Record<string, any>): void {
this.logger.error(this.formatMessage(message, meta), trace);
}
private formatMessage(message: string, meta?: Record<string, any>): string {
return meta ? `${message} | ${JSON.stringify(meta)}` : message;
}
}

View File

@@ -1,22 +0,0 @@
import { ILogger } from './ILogger';
export function LogExecution(label?: string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
const logger: ILogger = this.logger;
const context = label || `${target.constructor.name}.${propertyKey}`;
const start = Date.now();
logger.log(`Iniciando: ${context} | ${JSON.stringify({ args })}`);
const result = await original.apply(this, args);
const duration = Date.now() - start;
logger.log(`Finalizado: ${context} em ${duration}ms`);
return result;
};
return descriptor;
};
}

View File

@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { NestLoggerAdapter } from './NestLoggerAdapter';
import { ILogger } from './ILogger';
@Module({
providers: [
{
provide: 'LoggerService',
useFactory: () => new NestLoggerAdapter('DataConsultService'),
},
],
exports: ['LoggerService'],
})
export class LoggerModule {}

View File

@@ -8,32 +8,23 @@ import { OrdersPaymentModule } from './orders-payment/orders-payment.module';
import { AuthModule } from './auth/auth/auth.module';
import { DataConsultModule } from './data-consult/data-consult.module';
import { OrdersModule } from './orders/modules/orders.module';
import { OcorrencesController } from './crm/occurrences/ocorrences.controller';
import { OccurrencesModule } from './crm/occurrences/occurrences.module';
import { ReasonTableModule } from './crm/reason-table/reason-table.module';
import { NegotiationsModule } from './crm/negotiations/negotiations.module';
import { HttpModule } from '@nestjs/axios';
import { DebModule } from './orders/modules/deb.module';
import { LogisticController } from './logistic/logistic.controller';
import { LogisticService } from './logistic/logistic.service';
import { LoggerModule } from './Log/logger.module';
import jwtConfig from './auth/jwt.config';
import { UsersModule } from './auth/users/users.module';
import { ProductsModule } from './products/products.module';
import { ThrottlerModule, ThrottlerModuleOptions } from '@nestjs/throttler';
import { RateLimiterMiddleware } from './common/middlewares/rate-limiter.middleware';
import { RequestSanitizerMiddleware } from './common/middlewares/request-sanitizer.middleware';
import { HealthModule } from './health/health.module';
import { clientes } from './data-consult/clientes.module';
import { PartnersModule } from './partners/partners.module';
@Module({
imports: [
UsersModule,
ConfigModule.forRoot({ isGlobal: true,
load: [jwtConfig]
}),
ConfigModule.forRoot({ isGlobal: true, load: [jwtConfig] }),
TypeOrmModule.forRootAsync({
name: 'oracle',
inject: [ConfigService],
@@ -62,28 +53,19 @@ import { PartnersModule } from './partners/partners.module';
OrdersModule,
clientes,
ProductsModule,
NegotiationsModule,
OccurrencesModule,
ReasonTableModule,
LoggerModule,
DataConsultModule,
AuthModule,
DebModule,
OrdersModule,
HealthModule,
PartnersModule,
],
controllers: [OcorrencesController, LogisticController ],
providers: [ LogisticService,],
controllers: [LogisticController],
providers: [LogisticService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(RequestSanitizerMiddleware)
.forRoutes('*');
consumer.apply(RequestSanitizerMiddleware).forRoutes('*');
consumer
.apply(RateLimiterMiddleware)
.forRoutes('auth', 'users');
consumer.apply(RateLimiterMiddleware).forRoutes('auth', 'users');
}
}

View File

@@ -46,7 +46,9 @@ export interface AuthServiceTestContext {
mockUserRepository: ReturnType<typeof createMockUserRepository>;
mockTokenBlacklistService: ReturnType<typeof createMockTokenBlacklistService>;
mockRefreshTokenService: ReturnType<typeof createMockRefreshTokenService>;
mockSessionManagementService: ReturnType<typeof createMockSessionManagementService>;
mockSessionManagementService: ReturnType<
typeof createMockSessionManagementService
>;
}
export async function createAuthServiceTestModule(): Promise<AuthServiceTestContext> {
@@ -101,4 +103,3 @@ export async function createAuthServiceTestModule(): Promise<AuthServiceTestCont
mockSessionManagementService,
};
}

View File

@@ -29,7 +29,7 @@ describe('AuthService - createToken', () => {
username,
email,
storeId,
sessionId
sessionId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
@@ -41,7 +41,7 @@ describe('AuthService - createToken', () => {
email: email,
sessionId: sessionId,
},
{ expiresIn: '8h' }
{ expiresIn: '8h' },
);
expect(result).toBe(mockToken);
});
@@ -61,7 +61,7 @@ describe('AuthService - createToken', () => {
sellerId,
username,
email,
storeId
storeId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
@@ -73,7 +73,7 @@ describe('AuthService - createToken', () => {
email: email,
sessionId: undefined,
},
{ expiresIn: '8h' }
{ expiresIn: '8h' },
);
expect(result).toBe(mockToken);
});
@@ -93,12 +93,12 @@ describe('AuthService - createToken', () => {
sellerId,
username,
email,
storeId
storeId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
expect.any(Object),
{ expiresIn: '8h' }
{ expiresIn: '8h' },
);
});
@@ -119,7 +119,7 @@ describe('AuthService - createToken', () => {
username,
email,
storeId,
sessionId
sessionId,
);
const signCall = context.mockJwtService.sign.mock.calls[0];
@@ -150,7 +150,7 @@ describe('AuthService - createToken', () => {
username,
email,
storeId,
sessionId
sessionId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
@@ -162,7 +162,7 @@ describe('AuthService - createToken', () => {
email: email,
sessionId: sessionId,
},
{ expiresIn: '8h' }
{ expiresIn: '8h' },
);
expect(result).toBe(mockToken);
});
@@ -171,7 +171,13 @@ describe('AuthService - createToken', () => {
const mockToken = 'mock.jwt.token.once';
context.mockJwtService.sign.mockReturnValue(mockToken);
await context.service.createToken(1, 100, 'test', 'test@test.com', 'STORE001');
await context.service.createToken(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(context.mockJwtService.sign).toHaveBeenCalledTimes(1);
});
@@ -199,7 +205,13 @@ describe('AuthService - createToken', () => {
const negativeId = -1;
await expect(
context.service.createToken(negativeId, 100, 'test', 'test@test.com', 'STORE001')
context.service.createToken(
negativeId,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de usuário inválido');
});
@@ -207,7 +219,13 @@ describe('AuthService - createToken', () => {
const zeroId = 0;
await expect(
context.service.createToken(zeroId, 100, 'test', 'test@test.com', 'STORE001')
context.service.createToken(
zeroId,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de usuário inválido');
});
@@ -215,15 +233,93 @@ describe('AuthService - createToken', () => {
const negativeSellerId = -1;
await expect(
context.service.createToken(1, negativeSellerId, 'test', 'test@test.com', 'STORE001')
context.service.createToken(
1,
negativeSellerId,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de vendedor inválido');
});
it('should accept null seller ID', async () => {
const mockToken = 'mock.jwt.token.null.seller';
const userId = 1427;
const sellerId = null;
const username = 'brunelle.c';
const email = 'brunelle.c@jurunense.com.br';
const storeId = '12';
const sessionId = 'session-null-seller';
context.mockJwtService.sign.mockReturnValue(mockToken);
const result = await context.service.createToken(
userId,
sellerId,
username,
email,
storeId,
sessionId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
{
id: userId,
sellerId: null,
storeId: storeId,
username: username,
email: email,
sessionId: sessionId,
},
{ expiresIn: '8h' },
);
expect(result).toBe(mockToken);
});
it('should accept undefined seller ID', async () => {
const mockToken = 'mock.jwt.token.undefined.seller';
const userId = 1427;
const sellerId = undefined;
const username = 'brunelle.c';
const email = 'brunelle.c@jurunense.com.br';
const storeId = '12';
context.mockJwtService.sign.mockReturnValue(mockToken);
const result = await context.service.createToken(
userId,
sellerId as any,
username,
email,
storeId,
);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
{
id: userId,
sellerId: undefined,
storeId: storeId,
username: username,
email: email,
sessionId: undefined,
},
{ expiresIn: '8h' },
);
expect(result).toBe(mockToken);
});
it('should reject empty username', async () => {
const emptyUsername = '';
await expect(
context.service.createToken(1, 100, emptyUsername, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
emptyUsername,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário não pode estar vazio');
});
@@ -231,7 +327,13 @@ describe('AuthService - createToken', () => {
const whitespaceUsername = ' ';
await expect(
context.service.createToken(1, 100, whitespaceUsername, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
whitespaceUsername,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário não pode estar vazio');
});
@@ -239,7 +341,7 @@ describe('AuthService - createToken', () => {
const emptyEmail = '';
await expect(
context.service.createToken(1, 100, 'test', emptyEmail, 'STORE001')
context.service.createToken(1, 100, 'test', emptyEmail, 'STORE001'),
).rejects.toThrow('Email não pode estar vazio');
});
@@ -247,7 +349,7 @@ describe('AuthService - createToken', () => {
const invalidEmail = 'not-an-email';
await expect(
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001')
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001'),
).rejects.toThrow('Formato de email inválido');
});
@@ -255,7 +357,7 @@ describe('AuthService - createToken', () => {
const invalidEmail = 'testemail.com';
await expect(
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001')
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001'),
).rejects.toThrow('Formato de email inválido');
});
@@ -263,19 +365,37 @@ describe('AuthService - createToken', () => {
const emptyStoreId = '';
await expect(
context.service.createToken(1, 100, 'test', 'test@test.com', emptyStoreId)
context.service.createToken(
1,
100,
'test',
'test@test.com',
emptyStoreId,
),
).rejects.toThrow('ID da loja não pode estar vazio');
});
it('should reject null username', async () => {
await expect(
context.service.createToken(1, 100, null as any, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
null as any,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário não pode estar vazio');
});
it('should reject undefined email', async () => {
await expect(
context.service.createToken(1, 100, 'test', undefined as any, 'STORE001')
context.service.createToken(
1,
100,
'test',
undefined as any,
'STORE001',
),
).rejects.toThrow('Email não pode estar vazio');
});
@@ -283,7 +403,13 @@ describe('AuthService - createToken', () => {
const specialCharsOnly = '@#$%';
await expect(
context.service.createToken(1, 100, specialCharsOnly, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
specialCharsOnly,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário inválido');
});
@@ -291,7 +417,13 @@ describe('AuthService - createToken', () => {
const longUsername = 'a'.repeat(10000);
await expect(
context.service.createToken(1, 100, longUsername, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
longUsername,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário muito longo');
});
@@ -299,7 +431,7 @@ describe('AuthService - createToken', () => {
const longEmail = 'a'.repeat(10000) + '@test.com';
await expect(
context.service.createToken(1, 100, 'test', longEmail, 'STORE001')
context.service.createToken(1, 100, 'test', longEmail, 'STORE001'),
).rejects.toThrow('Email muito longo');
});
@@ -307,7 +439,13 @@ describe('AuthService - createToken', () => {
const sqlInjection = "admin'; DROP TABLE users; --";
await expect(
context.service.createToken(1, 100, sqlInjection, 'test@test.com', 'STORE001')
context.service.createToken(
1,
100,
sqlInjection,
'test@test.com',
'STORE001',
),
).rejects.toThrow('Nome de usuário contém caracteres inválidos');
});
@@ -315,9 +453,8 @@ describe('AuthService - createToken', () => {
const invalidEmail = 'test@@example.com';
await expect(
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001')
context.service.createToken(1, 100, 'test', invalidEmail, 'STORE001'),
).rejects.toThrow('Formato de email inválido');
});
});
});

View File

@@ -25,7 +25,9 @@ describe('AuthService - createTokenPair', () => {
beforeEach(() => {
context.mockJwtService.sign.mockReturnValue('mock.access.token');
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue('mock.refresh.token');
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue(
'mock.refresh.token',
);
});
it('should handle error when createToken fails after refresh token is generated', async () => {
@@ -39,10 +41,19 @@ describe('AuthService - createTokenPair', () => {
});
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-123')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-123',
),
).rejects.toThrow();
expect(context.mockRefreshTokenService.generateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.generateRefreshToken,
).not.toHaveBeenCalled();
});
it('should rollback access token if refresh token generation fails', async () => {
@@ -52,11 +63,18 @@ describe('AuthService - createTokenPair', () => {
* Solução esperada: Invalidar o access token ou garantir atomicidade.
*/
context.mockRefreshTokenService.generateRefreshToken.mockRejectedValueOnce(
new Error('Falha ao gerar refresh token')
new Error('Falha ao gerar refresh token'),
);
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-123')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-123',
),
).rejects.toThrow('Falha ao gerar refresh token');
});
@@ -69,7 +87,13 @@ describe('AuthService - createTokenPair', () => {
context.mockJwtService.sign.mockReturnValue('');
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('Token de acesso inválido gerado');
});
@@ -79,18 +103,34 @@ describe('AuthService - createTokenPair', () => {
* Problema: Método não valida o retorno.
* Solução esperada: Lançar exceção se token for inválido.
*/
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue('');
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue(
'',
);
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('Refresh token inválido gerado');
});
it('should validate that refresh token is not null', async () => {
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue(null);
context.mockRefreshTokenService.generateRefreshToken.mockResolvedValue(
null,
);
await expect(
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('Refresh token inválido gerado');
});
@@ -107,12 +147,20 @@ describe('AuthService - createTokenPair', () => {
return 'mock.access.token';
});
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(async () => {
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(
async () => {
callOrder.push('refreshToken');
return 'mock.refresh.token';
});
},
);
await context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001');
await context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(callOrder).toEqual(['accessToken', 'refreshToken']);
});
@@ -123,7 +171,13 @@ describe('AuthService - createTokenPair', () => {
* Problema: Cliente pode não saber quando renovar o token.
* Solução esperada: Sempre retornar um número positivo válido.
*/
const result = await context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001');
const result = await context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(result.expiresIn).toBeGreaterThan(0);
expect(typeof result.expiresIn).toBe('number');
@@ -145,19 +199,42 @@ describe('AuthService - createTokenPair', () => {
return `mock.access.token.${callCount}`;
});
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(async () => {
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(
async () => {
return `mock.refresh.token.${Math.random()}`;
});
},
);
const promises = [
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-1'),
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-2'),
context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001', 'session-3'),
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-1',
),
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-2',
),
context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
'session-3',
),
];
const results = await Promise.all(promises);
const uniqueTokens = new Set(results.map(r => r.accessToken));
const uniqueTokens = new Set(results.map((r) => r.accessToken));
expect(uniqueTokens.size).toBe(3);
});
@@ -168,10 +245,18 @@ describe('AuthService - createTokenPair', () => {
* Solução esperada: Falhar rápido com mensagem clara.
*/
await expect(
context.service.createTokenPair(-1, 100, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
-1,
100,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de usuário inválido');
expect(context.mockRefreshTokenService.generateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.generateRefreshToken,
).not.toHaveBeenCalled();
});
it('should not create refresh token if validation fails', async () => {
@@ -181,11 +266,19 @@ describe('AuthService - createTokenPair', () => {
* Solução esperada: Validar tudo antes de criar qualquer token.
*/
await expect(
context.service.createTokenPair(1, -1, 'test', 'test@test.com', 'STORE001')
context.service.createTokenPair(
1,
-1,
'test',
'test@test.com',
'STORE001',
),
).rejects.toThrow('ID de vendedor inválido');
expect(context.mockJwtService.sign).not.toHaveBeenCalled();
expect(context.mockRefreshTokenService.generateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.generateRefreshToken,
).not.toHaveBeenCalled();
});
it('should handle undefined sessionId gracefully', async () => {
@@ -194,11 +287,19 @@ describe('AuthService - createTokenPair', () => {
* Problema: Pode causar problemas ao gerar tokens sem session.
* Solução esperada: Aceitar undefined e passar corretamente aos serviços.
*/
const result = await context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001');
const result = await context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(result.accessToken).toBeDefined();
expect(result.refreshToken).toBeDefined();
expect(context.mockRefreshTokenService.generateRefreshToken).toHaveBeenCalledWith(1, undefined);
expect(
context.mockRefreshTokenService.generateRefreshToken,
).toHaveBeenCalledWith(1, undefined);
});
it('should include all required fields in return object', async () => {
@@ -207,13 +308,55 @@ describe('AuthService - createTokenPair', () => {
* Problema: Pode faltar campos ou ter campos extras.
* Solução esperada: Sempre retornar accessToken, refreshToken e expiresIn.
*/
const result = await context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001');
const result = await context.service.createTokenPair(
1,
100,
'test',
'test@test.com',
'STORE001',
);
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('refreshToken');
expect(result).toHaveProperty('expiresIn');
expect(Object.keys(result).length).toBe(3);
});
});
});
it('should create token pair with null seller ID', async () => {
/**
* Cenário: Usuário sem sellerId (CODUSUR NULL no banco).
* Problema: Validação anterior rejeitava null sellerId.
* Solução esperada: Aceitar null sellerId e criar tokens normalmente.
*/
const result = await context.service.createTokenPair(
1427,
null,
'BRUNELLE BENILDA GAMA COSTA',
'BRUNELLE.C@JURUNENSE.COM.BR',
'12',
'session-null-seller',
);
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('refreshToken');
expect(result).toHaveProperty('expiresIn');
expect(result.expiresIn).toBe(28800); // 8 horas em segundos
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
{
id: 1427,
sellerId: null,
storeId: '12',
username: 'BRUNELLE BENILDA GAMA COSTA',
email: 'BRUNELLE.C@JURUNENSE.COM.BR',
sessionId: 'session-null-seller',
},
{ expiresIn: '8h' },
);
expect(
context.mockRefreshTokenService.generateRefreshToken,
).toHaveBeenCalledWith(1427, 'session-null-seller');
});
});
});

View File

@@ -13,8 +13,12 @@ describe('AuthService - logout', () => {
storeId: 'STORE001',
sessionId: 'session-123',
});
context.mockTokenBlacklistService.addToBlacklist.mockResolvedValue(undefined);
context.mockSessionManagementService.terminateSession.mockResolvedValue(undefined);
context.mockTokenBlacklistService.addToBlacklist.mockResolvedValue(
undefined,
);
context.mockSessionManagementService.terminateSession.mockResolvedValue(
undefined,
);
});
afterEach(() => {
@@ -37,66 +41,76 @@ describe('AuthService - logout', () => {
*/
it('should reject empty token', async () => {
await expect(
context.service.logout('')
).rejects.toThrow('Token não pode estar vazio');
await expect(context.service.logout('')).rejects.toThrow(
'Token não pode estar vazio',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should reject null token', async () => {
await expect(
context.service.logout(null as any)
).rejects.toThrow('Token não pode estar vazio');
await expect(context.service.logout(null as any)).rejects.toThrow(
'Token não pode estar vazio',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should reject undefined token', async () => {
await expect(
context.service.logout(undefined as any)
).rejects.toThrow('Token não pode estar vazio');
await expect(context.service.logout(undefined as any)).rejects.toThrow(
'Token não pode estar vazio',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should reject whitespace-only token', async () => {
await expect(
context.service.logout(' ')
).rejects.toThrow('Token não pode estar vazio');
await expect(context.service.logout(' ')).rejects.toThrow(
'Token não pode estar vazio',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should reject extremely long tokens (DoS prevention)', async () => {
const hugeToken = 'a'.repeat(100000);
await expect(
context.service.logout(hugeToken)
).rejects.toThrow('Token muito longo');
await expect(context.service.logout(hugeToken)).rejects.toThrow(
'Token muito longo',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should validate decoded token is not null', async () => {
context.mockJwtService.decode.mockReturnValue(null);
await expect(
context.service.logout('invalid.token')
).rejects.toThrow('Token inválido ou não pode ser decodificado');
await expect(context.service.logout('invalid.token')).rejects.toThrow(
'Token inválido ou não pode ser decodificado',
);
});
it('should validate decoded token has required fields', async () => {
context.mockJwtService.decode.mockReturnValue({} as any);
await expect(
context.service.logout('incomplete.token')
).rejects.toThrow('Token inválido ou não pode ser decodificado');
await expect(context.service.logout('incomplete.token')).rejects.toThrow(
'Token inválido ou não pode ser decodificado',
);
});
it('should not add token to blacklist if already blacklisted', async () => {
@@ -104,7 +118,9 @@ describe('AuthService - logout', () => {
await context.service.logout('already.blacklisted.token');
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should validate session exists before terminating', async () => {
@@ -114,11 +130,11 @@ describe('AuthService - logout', () => {
} as any);
context.mockSessionManagementService.terminateSession.mockRejectedValue(
new Error('Sessão não encontrada')
new Error('Sessão não encontrada'),
);
await expect(
context.service.logout('token.with.invalid.session')
context.service.logout('token.with.invalid.session'),
).rejects.toThrow('Sessão não encontrada');
});
@@ -128,16 +144,16 @@ describe('AuthService - logout', () => {
});
await expect(
context.service.logout('invalid.token.format')
context.service.logout('invalid.token.format'),
).rejects.toThrow('Token inválido ou não pode ser decodificado');
});
it('should sanitize token input', async () => {
const maliciousToken = "'; DROP TABLE users; --";
await expect(
context.service.logout(maliciousToken)
).rejects.toThrow('Formato de token inválido');
await expect(context.service.logout(maliciousToken)).rejects.toThrow(
'Formato de token inválido',
);
expect(context.mockJwtService.decode).not.toHaveBeenCalled();
});
@@ -149,7 +165,7 @@ describe('AuthService - logout', () => {
} as any);
await expect(
context.service.logout('token.with.invalid.id')
context.service.logout('token.with.invalid.id'),
).rejects.toThrow('ID de usuário inválido no token');
});
@@ -161,7 +177,9 @@ describe('AuthService - logout', () => {
await context.service.logout('token.with.empty.sessionid');
expect(context.mockSessionManagementService.terminateSession).not.toHaveBeenCalled();
expect(
context.mockSessionManagementService.terminateSession,
).not.toHaveBeenCalled();
});
it('should complete logout even if session termination fails', async () => {
@@ -172,23 +190,27 @@ describe('AuthService - logout', () => {
context.mockTokenBlacklistService.isBlacklisted.mockResolvedValue(false);
context.mockSessionManagementService.terminateSession.mockRejectedValue(
new Error('Falha ao terminar sessão')
new Error('Falha ao terminar sessão'),
);
await context.service.logout('valid.token');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledWith('valid.token');
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledWith('valid.token');
});
it('should not throw if token is already blacklisted', async () => {
context.mockTokenBlacklistService.isBlacklisted.mockResolvedValue(true);
context.mockTokenBlacklistService.addToBlacklist.mockRejectedValue(
new Error('Token já está na blacklist')
new Error('Token já está na blacklist'),
);
await context.service.logout('already.blacklisted.token');
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should validate token format before decoding', async () => {
@@ -214,7 +236,9 @@ describe('AuthService - logout', () => {
await Promise.all(promises);
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledTimes(3);
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledTimes(3);
});
it('should validate decoded payload structure', async () => {
@@ -223,11 +247,15 @@ describe('AuthService - logout', () => {
} as any);
await expect(
context.service.logout('token.with.invalid.structure')
context.service.logout('token.with.invalid.structure'),
).rejects.toThrow('Token inválido ou não pode ser decodificado');
expect(context.mockSessionManagementService.terminateSession).not.toHaveBeenCalled();
expect(context.mockTokenBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(
context.mockSessionManagementService.terminateSession,
).not.toHaveBeenCalled();
expect(
context.mockTokenBlacklistService.addToBlacklist,
).not.toHaveBeenCalled();
});
it('should ensure token is always blacklisted on success', async () => {
@@ -235,8 +263,12 @@ describe('AuthService - logout', () => {
await context.service.logout('valid.token');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledWith('valid.token');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledTimes(1);
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledWith('valid.token');
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledTimes(1);
});
it('should handle race condition when token becomes blacklisted between check and add', async () => {
@@ -248,13 +280,17 @@ describe('AuthService - logout', () => {
*/
context.mockTokenBlacklistService.isBlacklisted.mockResolvedValue(false);
context.mockTokenBlacklistService.addToBlacklist.mockRejectedValue(
new Error('Token já está na blacklist')
new Error('Token já está na blacklist'),
);
await context.service.logout('token.with.race.condition');
expect(context.mockTokenBlacklistService.isBlacklisted).toHaveBeenCalledWith('token.with.race.condition');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledWith('token.with.race.condition');
expect(
context.mockTokenBlacklistService.isBlacklisted,
).toHaveBeenCalledWith('token.with.race.condition');
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledWith('token.with.race.condition');
});
it('should throw error if addToBlacklist fails with non-blacklist error', async () => {
@@ -265,15 +301,21 @@ describe('AuthService - logout', () => {
*/
context.mockTokenBlacklistService.isBlacklisted.mockResolvedValue(false);
context.mockTokenBlacklistService.addToBlacklist.mockRejectedValue(
new Error('Erro de conexão com Redis')
new Error('Erro de conexão com Redis'),
);
await expect(
context.service.logout('token.with.blacklist.error')
).rejects.toThrow('Falha ao adicionar token à blacklist: Erro de conexão com Redis');
context.service.logout('token.with.blacklist.error'),
).rejects.toThrow(
'Falha ao adicionar token à blacklist: Erro de conexão com Redis',
);
expect(context.mockTokenBlacklistService.isBlacklisted).toHaveBeenCalledWith('token.with.blacklist.error');
expect(context.mockTokenBlacklistService.addToBlacklist).toHaveBeenCalledWith('token.with.blacklist.error');
expect(
context.mockTokenBlacklistService.isBlacklisted,
).toHaveBeenCalledWith('token.with.blacklist.error');
expect(
context.mockTokenBlacklistService.addToBlacklist,
).toHaveBeenCalledWith('token.with.blacklist.error');
});
it('should verify isBlacklisted is called before addToBlacklist', async () => {
@@ -286,11 +328,14 @@ describe('AuthService - logout', () => {
await context.service.logout('valid.token');
const isBlacklistedCallOrder = context.mockTokenBlacklistService.isBlacklisted.mock.invocationCallOrder[0];
const addToBlacklistCallOrder = context.mockTokenBlacklistService.addToBlacklist.mock.invocationCallOrder[0];
const isBlacklistedCallOrder =
context.mockTokenBlacklistService.isBlacklisted.mock
.invocationCallOrder[0];
const addToBlacklistCallOrder =
context.mockTokenBlacklistService.addToBlacklist.mock
.invocationCallOrder[0];
expect(isBlacklistedCallOrder).toBeLessThan(addToBlacklistCallOrder);
});
});
});

View File

@@ -19,7 +19,9 @@ describe('AuthService - refreshAccessToken', () => {
situacao: 'A',
dataDesligamento: null,
});
context.mockSessionManagementService.isSessionActive.mockResolvedValue(true);
context.mockSessionManagementService.isSessionActive.mockResolvedValue(
true,
);
});
afterEach(() => {
@@ -40,35 +42,43 @@ describe('AuthService - refreshAccessToken', () => {
*/
it('should reject empty refresh token', async () => {
await expect(
context.service.refreshAccessToken('')
).rejects.toThrow('Refresh token não pode estar vazio');
await expect(context.service.refreshAccessToken('')).rejects.toThrow(
'Refresh token não pode estar vazio',
);
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should reject null refresh token', async () => {
await expect(
context.service.refreshAccessToken(null as any)
context.service.refreshAccessToken(null as any),
).rejects.toThrow('Refresh token não pode estar vazio');
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should reject undefined refresh token', async () => {
await expect(
context.service.refreshAccessToken(undefined as any)
context.service.refreshAccessToken(undefined as any),
).rejects.toThrow('Refresh token não pode estar vazio');
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should reject whitespace-only refresh token', async () => {
await expect(
context.service.refreshAccessToken(' ')
).rejects.toThrow('Refresh token não pode estar vazio');
await expect(context.service.refreshAccessToken(' ')).rejects.toThrow(
'Refresh token não pode estar vazio',
);
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should validate tokenData has required id field', async () => {
@@ -77,15 +87,17 @@ describe('AuthService - refreshAccessToken', () => {
} as any);
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do refresh token inválidos');
});
it('should validate tokenData is not null', async () => {
context.mockRefreshTokenService.validateRefreshToken.mockResolvedValue(null);
context.mockRefreshTokenService.validateRefreshToken.mockResolvedValue(
null,
);
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do refresh token inválidos');
});
@@ -101,7 +113,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do usuário incompletos');
});
@@ -117,7 +129,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do usuário incompletos');
});
@@ -133,7 +145,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Dados do usuário incompletos');
});
@@ -141,7 +153,7 @@ describe('AuthService - refreshAccessToken', () => {
context.mockJwtService.sign.mockReturnValue('');
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Falha ao gerar novo token de acesso');
});
@@ -149,7 +161,7 @@ describe('AuthService - refreshAccessToken', () => {
context.mockJwtService.sign.mockReturnValue(null as any);
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Falha ao gerar novo token de acesso');
});
@@ -159,10 +171,12 @@ describe('AuthService - refreshAccessToken', () => {
sessionId: 'expired-session',
});
context.mockSessionManagementService.isSessionActive = jest.fn().mockResolvedValue(false);
context.mockSessionManagementService.isSessionActive = jest
.fn()
.mockResolvedValue(false);
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Sessão não está mais ativa');
});
@@ -178,7 +192,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('ID de vendedor inválido');
});
@@ -186,24 +200,30 @@ describe('AuthService - refreshAccessToken', () => {
const hugeToken = 'a'.repeat(100000);
await expect(
context.service.refreshAccessToken(hugeToken)
context.service.refreshAccessToken(hugeToken),
).rejects.toThrow('Refresh token muito longo');
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should sanitize refresh token input', async () => {
const maliciousToken = "'; DROP TABLE users; --";
await expect(
context.service.refreshAccessToken(maliciousToken)
context.service.refreshAccessToken(maliciousToken),
).rejects.toThrow('Formato de refresh token inválido');
expect(context.mockRefreshTokenService.validateRefreshToken).not.toHaveBeenCalled();
expect(
context.mockRefreshTokenService.validateRefreshToken,
).not.toHaveBeenCalled();
});
it('should include only required fields in response', async () => {
const result = await context.service.refreshAccessToken('valid.refresh.token');
const result = await context.service.refreshAccessToken(
'valid.refresh.token',
);
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('expiresIn');
@@ -213,7 +233,9 @@ describe('AuthService - refreshAccessToken', () => {
});
it('should validate expiresIn is correct', async () => {
const result = await context.service.refreshAccessToken('valid.refresh.token');
const result = await context.service.refreshAccessToken(
'valid.refresh.token',
);
expect(result.expiresIn).toBe(28800);
expect(result.expiresIn).toBeGreaterThan(0);
@@ -231,7 +253,7 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow();
});
@@ -244,7 +266,7 @@ describe('AuthService - refreshAccessToken', () => {
const results = await Promise.all(promises);
results.forEach(result => {
results.forEach((result) => {
expect(result).toHaveProperty('accessToken');
expect(result.accessToken).toBeTruthy();
});
@@ -262,9 +284,8 @@ describe('AuthService - refreshAccessToken', () => {
});
await expect(
context.service.refreshAccessToken('valid.refresh.token')
context.service.refreshAccessToken('valid.refresh.token'),
).rejects.toThrow('Usuário inválido ou inativo');
});
});
});

View File

@@ -24,14 +24,17 @@ import { RateLimitingGuard } from '../guards/rate-limiting.guard';
import { RateLimitingService } from '../services/rate-limiting.service';
import { RefreshTokenService } from '../services/refresh-token.service';
import { SessionManagementService } from '../services/session-management.service';
import { RefreshTokenDto, RefreshTokenResponseDto } from './dto/refresh-token.dto';
import {
RefreshTokenDto,
RefreshTokenResponseDto,
} from './dto/refresh-token.dto';
import { SessionsResponseDto } from './dto/session.dto';
import { LoginAuditService } from '../services/login-audit.service';
import {
LoginAuditFiltersDto,
LoginAuditResponseDto,
LoginStatsDto,
LoginStatsFiltersDto
LoginStatsFiltersDto,
} from './dto/login-audit.dto';
import {
ApiTags,
@@ -66,7 +69,10 @@ export class AuthController {
})
@ApiUnauthorizedResponse({ description: 'Usuário ou senha inválidos' })
@ApiTooManyRequestsResponse({ description: 'Muitas tentativas de login' })
async login(@Body() dto: LoginDto, @Request() req): Promise<LoginResponseDto> {
async login(
@Body() dto: LoginDto,
@Request() req,
): Promise<LoginResponseDto> {
const ip = this.getClientIp(req);
const command = new AuthenticateUserCommand(dto.username, dto.password);
@@ -98,13 +104,17 @@ export class AuthController {
/**
* Verifica se o usuário já possui uma sessão ativa
*/
const existingSession = await this.sessionManagementService.hasActiveSession(user.id);
const existingSession =
await this.sessionManagementService.hasActiveSession(user.id);
if (existingSession) {
/**
* Encerra a sessão existente antes de criar uma nova
*/
await this.sessionManagementService.terminateSession(user.id, existingSession.sessionId);
await this.sessionManagementService.terminateSession(
user.id,
existingSession.sessionId,
);
}
const session = await this.sessionManagementService.createSession(
@@ -161,7 +171,6 @@ export class AuthController {
);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@@ -173,7 +182,12 @@ export class AuthController {
if (!token) {
throw new HttpException(
new ResultModel(false, 'Token não fornecido', null, 'Token não fornecido'),
new ResultModel(
false,
'Token não fornecido',
null,
'Token não fornecido',
),
HttpStatus.UNAUTHORIZED,
);
}
@@ -192,8 +206,12 @@ export class AuthController {
description: 'Token renovado com sucesso',
type: RefreshTokenResponseDto,
})
@ApiUnauthorizedResponse({ description: 'Refresh token inválido ou expirado' })
async refreshToken(@Body() dto: RefreshTokenDto): Promise<RefreshTokenResponseDto> {
@ApiUnauthorizedResponse({
description: 'Refresh token inválido ou expirado',
})
async refreshToken(
@Body() dto: RefreshTokenDto,
): Promise<RefreshTokenResponseDto> {
const result = await this.authService.refreshAccessToken(dto.refreshToken);
return result;
}
@@ -210,15 +228,20 @@ export class AuthController {
async getSessions(@Request() req): Promise<SessionsResponseDto> {
const userId = req.user.id;
const currentSessionId = req.user.sessionId;
const sessions = await this.sessionManagementService.getActiveSessions(userId, currentSessionId);
const sessions = await this.sessionManagementService.getActiveSessions(
userId,
currentSessionId,
);
return {
sessions: sessions.map(session => ({
sessions: sessions.map((session) => ({
sessionId: session.sessionId,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
createdAt: DateUtil.toBrazilISOString(new Date(session.createdAt)),
lastActivity: DateUtil.toBrazilISOString(new Date(session.lastActivity)),
lastActivity: DateUtil.toBrazilISOString(
new Date(session.lastActivity),
),
isCurrent: session.sessionId === currentSessionId,
})),
total: sessions.length,
@@ -284,7 +307,7 @@ export class AuthController {
const logs = await this.loginAuditService.getLoginLogs(auditFilters);
return {
logs: logs.map(log => ({
logs: logs.map((log) => ({
...log,
timestamp: DateUtil.toBrazilISOString(log.timestamp),
})),
@@ -333,13 +356,12 @@ export class AuthController {
ipAddress: { type: 'string' },
userAgent: { type: 'string' },
createdAt: { type: 'string' },
lastActivity: { type: 'string' }
}
}
}
}
lastActivity: { type: 'string' },
},
},
},
},
})
@Get('session/status')
async checkSessionStatus(@Query('username') username: string): Promise<{
hasActiveSession: boolean;
@@ -353,7 +375,12 @@ export class AuthController {
}> {
if (!username) {
throw new HttpException(
new ResultModel(false, 'Username é obrigatório', null, 'Username é obrigatório'),
new ResultModel(
false,
'Username é obrigatório',
null,
'Username é obrigatório',
),
HttpStatus.BAD_REQUEST,
);
}
@@ -369,7 +396,9 @@ export class AuthController {
};
}
const activeSession = await this.sessionManagementService.hasActiveSession(user.id);
const activeSession = await this.sessionManagementService.hasActiveSession(
user.id,
);
if (!activeSession) {
return {
@@ -383,8 +412,12 @@ export class AuthController {
sessionId: activeSession.sessionId,
ipAddress: activeSession.ipAddress,
userAgent: activeSession.userAgent,
createdAt: DateUtil.toBrazilISOString(new Date(activeSession.createdAt)),
lastActivity: DateUtil.toBrazilISOString(new Date(activeSession.lastActivity)),
createdAt: DateUtil.toBrazilISOString(
new Date(activeSession.createdAt),
),
lastActivity: DateUtil.toBrazilISOString(
new Date(activeSession.lastActivity),
),
},
};
}

View File

@@ -42,7 +42,7 @@ import { LoginAuditService } from '../services/login-audit.service';
RefreshTokenService,
SessionManagementService,
LoginAuditService,
AuthenticateUserHandler
AuthenticateUserHandler,
],
exports: [AuthService],
})

View File

@@ -1,4 +1,8 @@
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import {
Injectable,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { JwtPayload } from '../models/jwt-payload.model';
@@ -7,7 +11,6 @@ import { TokenBlacklistService } from '../services/token-blacklist.service';
import { RefreshTokenService } from '../services/refresh-token.service';
import { SessionManagementService } from '../services/session-management.service';
@Injectable()
export class AuthService {
constructor(
@@ -23,7 +26,14 @@ export class AuthService {
* Cria um token JWT com validação de todos os parâmetros de entrada
* @throws BadRequestException quando os parâmetros são inválidos
*/
async createToken(id: number, sellerId: number, username: string, email: string, storeId: string, sessionId?: string) {
async createToken(
id: number,
sellerId: number | null,
username: string,
email: string,
storeId: string,
sessionId?: string,
) {
this.validateTokenParameters(id, sellerId, username, email, storeId);
const user: JwtPayload = {
@@ -42,12 +52,22 @@ export class AuthService {
* Valida os parâmetros de entrada para criação de token
* @private
*/
private validateTokenParameters(id: number, sellerId: number, username: string, email: string, storeId: string): void {
private validateTokenParameters(
id: number,
sellerId: number | null,
username: string,
email: string,
storeId: string,
): void {
if (!id || id <= 0) {
throw new BadRequestException('ID de usuário inválido');
}
if (sellerId === null || sellerId === undefined || sellerId < 0) {
if (
sellerId !== null &&
sellerId !== undefined &&
sellerId < 0
) {
throw new BadRequestException('ID de vendedor inválido');
}
@@ -64,7 +84,9 @@ export class AuthService {
}
if (/['";\\]/.test(username)) {
throw new BadRequestException('Nome de usuário contém caracteres inválidos');
throw new BadRequestException(
'Nome de usuário contém caracteres inválidos',
);
}
if (!email || typeof email !== 'string' || !email.trim()) {
@@ -92,16 +114,41 @@ export class AuthService {
* @throws BadRequestException quando os parâmetros são inválidos
* @throws Error quando os tokens gerados são inválidos
*/
async createTokenPair(id: number, sellerId: number, username: string, email: string, storeId: string, sessionId?: string) {
const accessToken = await this.createToken(id, sellerId, username, email, storeId, sessionId);
async createTokenPair(
id: number,
sellerId: number | null,
username: string,
email: string,
storeId: string,
sessionId?: string,
) {
const accessToken = await this.createToken(
id,
sellerId,
username,
email,
storeId,
sessionId,
);
if (!accessToken || typeof accessToken !== 'string' || !accessToken.trim()) {
if (
!accessToken ||
typeof accessToken !== 'string' ||
!accessToken.trim()
) {
throw new Error('Token de acesso inválido gerado');
}
const refreshToken = await this.refreshTokenService.generateRefreshToken(id, sessionId);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
id,
sessionId,
);
if (!refreshToken || typeof refreshToken !== 'string' || !refreshToken.trim()) {
if (
!refreshToken ||
typeof refreshToken !== 'string' ||
!refreshToken.trim()
) {
throw new Error('Refresh token inválido gerado');
}
@@ -121,7 +168,9 @@ export class AuthService {
async refreshAccessToken(refreshToken: string) {
this.validateRefreshTokenInput(refreshToken);
const tokenData = await this.refreshTokenService.validateRefreshToken(refreshToken);
const tokenData = await this.refreshTokenService.validateRefreshToken(
refreshToken,
);
if (!tokenData || !tokenData.id) {
throw new BadRequestException('Dados do refresh token inválidos');
@@ -135,9 +184,10 @@ export class AuthService {
this.validateUserDataForToken(user);
if (tokenData.sessionId) {
const isSessionActive = await this.sessionManagementService.isSessionActive(
const isSessionActive =
await this.sessionManagementService.isSessionActive(
user.id,
tokenData.sessionId
tokenData.sessionId,
);
if (!isSessionActive) {
throw new UnauthorizedException('Sessão não está mais ativa');
@@ -150,10 +200,14 @@ export class AuthService {
user.name,
user.email,
user.storeId,
tokenData.sessionId
tokenData.sessionId,
);
if (!newAccessToken || typeof newAccessToken !== 'string' || !newAccessToken.trim()) {
if (
!newAccessToken ||
typeof newAccessToken !== 'string' ||
!newAccessToken.trim()
) {
throw new Error('Falha ao gerar novo token de acesso');
}
@@ -168,7 +222,11 @@ export class AuthService {
* @private
*/
private validateRefreshTokenInput(refreshToken: string): void {
if (!refreshToken || typeof refreshToken !== 'string' || !refreshToken.trim()) {
if (
!refreshToken ||
typeof refreshToken !== 'string' ||
!refreshToken.trim()
) {
throw new BadRequestException('Refresh token não pode estar vazio');
}
@@ -187,18 +245,32 @@ export class AuthService {
*/
private validateUserDataForToken(user: any): void {
if (!user.name || typeof user.name !== 'string' || !user.name.trim()) {
throw new BadRequestException('Dados do usuário incompletos: nome não encontrado');
throw new BadRequestException(
'Dados do usuário incompletos: nome não encontrado',
);
}
if (!user.email || typeof user.email !== 'string' || !user.email.trim()) {
throw new BadRequestException('Dados do usuário incompletos: email não encontrado');
throw new BadRequestException(
'Dados do usuário incompletos: email não encontrado',
);
}
if (!user.storeId || typeof user.storeId !== 'string' || !user.storeId.trim()) {
throw new BadRequestException('Dados do usuário incompletos: storeId não encontrado');
if (
!user.storeId ||
typeof user.storeId !== 'string' ||
!user.storeId.trim()
) {
throw new BadRequestException(
'Dados do usuário incompletos: storeId não encontrado',
);
}
if (user.sellerId !== null && user.sellerId !== undefined && user.sellerId < 0) {
if (
user.sellerId !== null &&
user.sellerId !== undefined &&
user.sellerId < 0
) {
throw new BadRequestException('ID de vendedor inválido');
}
}
@@ -228,11 +300,15 @@ export class AuthService {
try {
decoded = this.jwtService.decode(token) as JwtPayload;
} catch (error) {
throw new BadRequestException('Token inválido ou não pode ser decodificado');
throw new BadRequestException(
'Token inválido ou não pode ser decodificado',
);
}
if (!decoded || !decoded.id) {
throw new BadRequestException('Token inválido ou não pode ser decodificado');
throw new BadRequestException(
'Token inválido ou não pode ser decodificado',
);
}
if (decoded.id <= 0) {
@@ -241,25 +317,34 @@ export class AuthService {
if (decoded.sessionId && decoded.id && decoded.sessionId.trim()) {
try {
await this.sessionManagementService.terminateSession(decoded.id, decoded.sessionId);
await this.sessionManagementService.terminateSession(
decoded.id,
decoded.sessionId,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Sessão não encontrada')) {
throw new Error('Sessão não encontrada');
}
}
}
const isAlreadyBlacklisted = await this.tokenBlacklistService.isBlacklisted(token);
const isAlreadyBlacklisted = await this.tokenBlacklistService.isBlacklisted(
token,
);
if (!isAlreadyBlacklisted) {
try {
await this.tokenBlacklistService.addToBlacklist(token);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes('já está na blacklist')) {
return;
}
throw new Error(`Falha ao adicionar token à blacklist: ${errorMessage}`);
throw new Error(
`Falha ao adicionar token à blacklist: ${errorMessage}`,
);
}
}
}

View File

@@ -4,4 +4,3 @@ export class AuthenticateUserCommand {
public readonly password: string,
) {}
}

View File

@@ -7,13 +7,18 @@ import { UserModel } from 'src/core/models/user.model';
@CommandHandler(AuthenticateUserCommand)
@Injectable()
export class AuthenticateUserHandler implements ICommandHandler<AuthenticateUserCommand> {
export class AuthenticateUserHandler
implements ICommandHandler<AuthenticateUserCommand>
{
constructor(private readonly userRepository: UserRepository) {}
async execute(command: AuthenticateUserCommand): Promise<Result<UserModel>> {
const { username, password } = command;
const user = await this.userRepository.findByUsernameAndPassword(username, password);
const user = await this.userRepository.findByUsernameAndPassword(
username,
password,
);
if (!user) {
return Result.fail('Usuário ou senha inválidos');
@@ -31,7 +36,6 @@ export class AuthenticateUserHandler implements ICommandHandler<AuthenticateUser
return Result.fail('Usuário bloqueado, login não permitido!');
}
return Result.ok(user);
}
}

View File

@@ -3,7 +3,7 @@ import { ApiProperty } from '@nestjs/swagger';
export class LoginResponseDto {
@ApiProperty() id: number;
@ApiProperty() sellerId: number;
@ApiProperty({ nullable: true }) sellerId: number | null;
@ApiProperty() name: string;
@ApiProperty() username: string;
@ApiProperty() storeId: string;

View File

@@ -1,5 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsNumber, IsString, IsBoolean, IsDateString, Min, Max } from 'class-validator';
import {
IsOptional,
IsNumber,
IsString,
IsBoolean,
IsDateString,
Min,
Max,
} from 'class-validator';
import { Type } from 'class-transformer';
export class LoginAuditFiltersDto {
@@ -19,7 +27,10 @@ export class LoginAuditFiltersDto {
@IsString()
ipAddress?: string;
@ApiProperty({ description: 'Filtrar apenas logins bem-sucedidos', required: false })
@ApiProperty({
description: 'Filtrar apenas logins bem-sucedidos',
required: false,
})
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
@@ -35,7 +46,12 @@ export class LoginAuditFiltersDto {
@IsDateString()
endDate?: string;
@ApiProperty({ description: 'Número de registros por página', required: false, minimum: 1, maximum: 1000 })
@ApiProperty({
description: 'Número de registros por página',
required: false,
minimum: 1,
maximum: 1000,
})
@IsOptional()
@IsNumber()
@Type(() => Number)
@@ -43,7 +59,11 @@ export class LoginAuditFiltersDto {
@Max(1000)
limit?: number;
@ApiProperty({ description: 'Offset para paginação', required: false, minimum: 0 })
@ApiProperty({
description: 'Offset para paginação',
required: false,
minimum: 0,
})
@IsOptional()
@IsNumber()
@Type(() => Number)
@@ -84,7 +104,10 @@ export class LoginAuditLogDto {
}
export class LoginAuditResponseDto {
@ApiProperty({ description: 'Lista de logs de login', type: [LoginAuditLogDto] })
@ApiProperty({
description: 'Lista de logs de login',
type: [LoginAuditLogDto],
})
logs: LoginAuditLogDto[];
@ApiProperty({ description: 'Total de registros encontrados' })
@@ -123,13 +146,21 @@ export class LoginStatsDto {
}
export class LoginStatsFiltersDto {
@ApiProperty({ description: 'ID do usuário para estatísticas', required: false })
@ApiProperty({
description: 'ID do usuário para estatísticas',
required: false,
})
@IsOptional()
@IsNumber()
@Type(() => Number)
userId?: number;
@ApiProperty({ description: 'Número de dias para análise', required: false, minimum: 1, maximum: 365 })
@ApiProperty({
description: 'Número de dias para análise',
required: false,
minimum: 1,
maximum: 365,
})
@IsOptional()
@IsNumber()
@Type(() => Number)

View File

@@ -196,7 +196,7 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockRejectedValue(
new Error('Erro de conexão com Redis')
new Error('Erro de conexão com Redis'),
);
try {
@@ -225,7 +225,7 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
mockRateLimitingService.getAttemptInfo.mockRejectedValue(
new Error('Erro ao buscar informações')
new Error('Erro ao buscar informações'),
);
try {
@@ -336,7 +336,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('192.168.1.1');
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith(
'192.168.1.1',
);
});
it('should handle concurrent requests with same IP', async () => {
@@ -363,7 +365,7 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
const results = await Promise.all(promises);
results.forEach(result => {
results.forEach((result) => {
expect(result).toBe(true);
});
});
@@ -394,7 +396,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
expect((error as HttpException).getStatus()).toBe(
HttpStatus.TOO_MANY_REQUESTS,
);
}
});
@@ -419,7 +423,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
fail('Deveria ter lançado exceção');
} catch (error) {
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Muitas tentativas de login. Tente novamente em alguns minutos.');
expect(response.error).toBe(
'Muitas tentativas de login. Tente novamente em alguns minutos.',
);
expect(response.success).toBe(false);
}
});
@@ -512,7 +518,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith(
'2001:0db8:85a3:0000:0000:8a2e:0370:7334',
);
});
it('should reject invalid IPv6 format', async () => {
@@ -556,7 +564,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
await guard.canActivate(mockExecutionContext);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('192.168.1.1');
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith(
'192.168.1.1',
);
});
it('should fallback to connection.remoteAddress when x-forwarded-for is missing', async () => {
@@ -572,7 +582,9 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
await guard.canActivate(mockExecutionContext);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('10.0.0.1');
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith(
'10.0.0.1',
);
});
it('should use default IP when all sources are missing', async () => {
@@ -603,4 +615,3 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
});
});
});

View File

@@ -1,4 +1,10 @@
import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { RateLimitingService } from '../services/rate-limiting.service';
@Injectable()
@@ -19,7 +25,8 @@ export class RateLimitingGuard implements CanActivate {
try {
isAllowed = await this.rateLimitingService.isAllowed(ip);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new HttpException(
{
success: false,
@@ -36,7 +43,8 @@ export class RateLimitingGuard implements CanActivate {
try {
attemptInfo = await this.rateLimitingService.getAttemptInfo(ip);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new HttpException(
{
success: false,
@@ -53,7 +61,8 @@ export class RateLimitingGuard implements CanActivate {
throw new HttpException(
{
success: false,
error: 'Muitas tentativas de login. Tente novamente em alguns minutos.',
error:
'Muitas tentativas de login. Tente novamente em alguns minutos.',
data: null,
details: {
attempts: attemptInfo.attempts,
@@ -73,13 +82,16 @@ export class RateLimitingGuard implements CanActivate {
* @returns Endereço IP do cliente ou '127.0.0.1' se não encontrado
*/
private getClientIp(request: any): string {
const forwardedFor = request.headers['x-forwarded-for']?.split(',')[0]?.trim();
const forwardedFor = request.headers['x-forwarded-for']
?.split(',')[0]
?.trim();
const realIp = request.headers['x-real-ip']?.trim();
const connectionIp = request.connection?.remoteAddress;
const socketIp = request.socket?.remoteAddress;
const requestIp = request.ip;
const rawIp = forwardedFor || realIp || connectionIp || socketIp || requestIp;
const rawIp =
forwardedFor || realIp || connectionIp || socketIp || requestIp;
if (rawIp === null || rawIp === undefined) {
return '';
@@ -144,7 +156,11 @@ export class RateLimitingGuard implements CanActivate {
return;
}
if (!ipv4Regex.test(ip) && !ipv6Regex.test(ip) && !ipv6CompressedRegex.test(ip)) {
if (
!ipv4Regex.test(ip) &&
!ipv6Regex.test(ip) &&
!ipv6CompressedRegex.test(ip)
) {
if (!this.isValidIpv4(ip) && !this.isValidIpv6(ip)) {
throw new HttpException(
{
@@ -166,7 +182,7 @@ export class RateLimitingGuard implements CanActivate {
const parts = ip.split('.');
if (parts.length !== 4) return false;
return parts.every(part => {
return parts.every((part) => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255;
});
@@ -184,13 +200,13 @@ export class RateLimitingGuard implements CanActivate {
const leftParts = parts[0] ? parts[0].split(':') : [];
const rightParts = parts[1] ? parts[1].split(':') : [];
return (leftParts.length + rightParts.length) <= 8;
return leftParts.length + rightParts.length <= 8;
}
const parts = ip.split(':');
if (parts.length !== 8) return false;
return parts.every(part => {
return parts.every((part) => {
if (!part) return false;
return /^[0-9a-fA-F]{1,4}$/.test(part);
});
@@ -223,8 +239,11 @@ export class RateLimitingGuard implements CanActivate {
);
}
if (attemptInfo.remainingTime !== undefined &&
(typeof attemptInfo.remainingTime !== 'number' || attemptInfo.remainingTime < 0)) {
if (
attemptInfo.remainingTime !== undefined &&
(typeof attemptInfo.remainingTime !== 'number' ||
attemptInfo.remainingTime < 0)
) {
throw new HttpException(
{
success: false,

View File

@@ -1,7 +1,7 @@
/* eslint-disable prettier/prettier */
export interface JwtPayload {
id: number;
sellerId: number;
sellerId: number | null;
storeId: string;
username: string;
email: string;

View File

@@ -13,4 +13,3 @@ export class Result<T> {
return new Result<U>(false, undefined, message);
}
}

View File

@@ -0,0 +1,64 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RefreshTokenService } from '../refresh-token.service';
import { IRedisClient } from '../../../core/configs/cache/IRedisClient';
import { RedisClientToken } from '../../../core/configs/cache/redis-client.adapter.provider';
import { JwtService } from '@nestjs/jwt';
export const createMockRedisClient = () =>
({
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
keys: jest.fn(),
} as any);
export const createMockJwtService = () =>
({
sign: jest.fn(),
verify: jest.fn(),
decode: jest.fn(),
} as any);
export interface RefreshTokenServiceTestContext {
service: RefreshTokenService;
mockRedisClient: jest.Mocked<IRedisClient>;
mockJwtService: jest.Mocked<JwtService>;
}
export async function createRefreshTokenServiceTestModule(
redisClientMethods: Partial<IRedisClient> = {},
jwtServiceMethods: Partial<JwtService> = {},
): Promise<RefreshTokenServiceTestContext> {
const mockRedisClient = {
...createMockRedisClient(),
...redisClientMethods,
} as any;
const mockJwtService = {
...createMockJwtService(),
...jwtServiceMethods,
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
RefreshTokenService,
{
provide: RedisClientToken,
useValue: mockRedisClient,
},
{
provide: JwtService,
useValue: mockJwtService,
},
],
}).compile();
const service = module.get<RefreshTokenService>(RefreshTokenService);
return {
service,
mockRedisClient,
mockJwtService,
};
}

View File

@@ -0,0 +1,392 @@
import { UnauthorizedException } from '@nestjs/common';
import { createRefreshTokenServiceTestModule } from './refresh-token.service.spec.helper';
import { RefreshTokenData } from '../refresh-token.service';
describe('RefreshTokenService', () => {
describe('generateRefreshToken', () => {
let context: Awaited<
ReturnType<typeof createRefreshTokenServiceTestModule>
>;
beforeEach(async () => {
context = await createRefreshTokenServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve gerar refresh token com sucesso', async () => {
const userId = 123;
const sessionId = 'session-123';
const mockToken = 'mock.refresh.token';
const mockTokenId = 'token-id-123';
jest.spyOn(require('crypto'), 'randomBytes').mockReturnValue({
toString: () => mockTokenId,
});
context.mockJwtService.sign.mockReturnValue(mockToken);
context.mockRedisClient.set.mockResolvedValue(undefined);
context.mockRedisClient.keys.mockResolvedValue([]);
const result = await context.service.generateRefreshToken(
userId,
sessionId,
);
expect(result).toBe(mockToken);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
{
userId,
tokenId: mockTokenId,
sessionId,
type: 'refresh',
},
{ expiresIn: '7d' },
);
expect(context.mockRedisClient.set).toHaveBeenCalled();
});
it('deve gerar refresh token sem sessionId', async () => {
const userId = 123;
const mockToken = 'mock.refresh.token';
const mockTokenId = 'token-id-123';
jest.spyOn(require('crypto'), 'randomBytes').mockReturnValue({
toString: () => mockTokenId,
});
context.mockJwtService.sign.mockReturnValue(mockToken);
context.mockRedisClient.set.mockResolvedValue(undefined);
context.mockRedisClient.keys.mockResolvedValue([]);
const result = await context.service.generateRefreshToken(userId);
expect(result).toBe(mockToken);
expect(context.mockJwtService.sign).toHaveBeenCalledWith(
{
userId,
tokenId: mockTokenId,
sessionId: undefined,
type: 'refresh',
},
{ expiresIn: '7d' },
);
});
it('deve limitar número de refresh tokens por usuário', async () => {
const userId = 123;
const mockToken = 'mock.refresh.token';
const mockTokenId = 'token-id-123';
jest.spyOn(require('crypto'), 'randomBytes').mockReturnValue({
toString: () => mockTokenId,
});
context.mockJwtService.sign.mockReturnValue(mockToken);
context.mockRedisClient.set.mockResolvedValue(undefined);
const existingTokens: RefreshTokenData[] = Array.from(
{ length: 6 },
(_, i) => ({
userId,
tokenId: `token-${i}`,
expiresAt: Date.now() + 1000000,
createdAt: Date.now(),
}),
);
context.mockRedisClient.keys.mockResolvedValue([
'auth:refresh_tokens:123:token-0',
'auth:refresh_tokens:123:token-1',
'auth:refresh_tokens:123:token-2',
'auth:refresh_tokens:123:token-3',
'auth:refresh_tokens:123:token-4',
'auth:refresh_tokens:123:token-5',
]);
context.mockRedisClient.get
.mockResolvedValueOnce(existingTokens[0])
.mockResolvedValueOnce(existingTokens[1])
.mockResolvedValueOnce(existingTokens[2])
.mockResolvedValueOnce(existingTokens[3])
.mockResolvedValueOnce(existingTokens[4])
.mockResolvedValueOnce(existingTokens[5]);
await context.service.generateRefreshToken(userId);
expect(context.mockRedisClient.del).toHaveBeenCalled();
});
});
describe('validateRefreshToken', () => {
let context: Awaited<
ReturnType<typeof createRefreshTokenServiceTestModule>
>;
beforeEach(async () => {
context = await createRefreshTokenServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve validar refresh token com sucesso', async () => {
const mockDecoded = {
userId: 123,
tokenId: 'token-id-123',
sessionId: 'session-123',
type: 'refresh',
};
const mockTokenData: RefreshTokenData = {
userId: 123,
tokenId: 'token-id-123',
sessionId: 'session-123',
expiresAt: Date.now() + 1000000,
createdAt: Date.now(),
};
context.mockJwtService.verify.mockReturnValue(mockDecoded);
context.mockRedisClient.get.mockResolvedValue(mockTokenData);
const result = await context.service.validateRefreshToken(
'valid.refresh.token',
);
expect(result.id).toBe(123);
expect((result as any).tokenId).toBe('token-id-123');
expect(result.sessionId).toBe('session-123');
});
it('deve lançar exceção quando token não é do tipo refresh', async () => {
const mockDecoded = {
userId: 123,
tokenId: 'token-id-123',
type: 'access',
};
context.mockJwtService.verify.mockReturnValue(mockDecoded);
await expect(
context.service.validateRefreshToken('invalid.token'),
).rejects.toThrow(UnauthorizedException);
});
it('deve lançar exceção quando token não existe no Redis', async () => {
const mockDecoded = {
userId: 123,
tokenId: 'token-id-123',
sessionId: 'session-123',
type: 'refresh',
};
context.mockJwtService.verify.mockReturnValue(mockDecoded);
context.mockRedisClient.get.mockResolvedValue(null);
await expect(
context.service.validateRefreshToken('expired.token'),
).rejects.toThrow(UnauthorizedException);
});
it('deve lançar exceção quando token está expirado', async () => {
const mockDecoded = {
userId: 123,
tokenId: 'token-id-123',
sessionId: 'session-123',
type: 'refresh',
};
const mockTokenData: RefreshTokenData = {
userId: 123,
tokenId: 'token-id-123',
sessionId: 'session-123',
expiresAt: Date.now() - 1000,
createdAt: Date.now() - 1000000,
};
context.mockJwtService.verify.mockReturnValue(mockDecoded);
context.mockRedisClient.get.mockResolvedValue(mockTokenData);
context.mockRedisClient.del.mockResolvedValue(undefined);
await expect(
context.service.validateRefreshToken('expired.token'),
).rejects.toThrow(UnauthorizedException);
expect(context.mockRedisClient.del).toHaveBeenCalled();
});
it('deve lançar exceção quando verificação do JWT falha', async () => {
context.mockJwtService.verify.mockImplementation(() => {
throw new Error('Token inválido');
});
await expect(
context.service.validateRefreshToken('invalid.token'),
).rejects.toThrow(UnauthorizedException);
});
});
describe('revokeRefreshToken', () => {
let context: Awaited<
ReturnType<typeof createRefreshTokenServiceTestModule>
>;
beforeEach(async () => {
context = await createRefreshTokenServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve revogar refresh token com sucesso', async () => {
const userId = 123;
const tokenId = 'token-id-123';
context.mockRedisClient.del.mockResolvedValue(undefined);
await context.service.revokeRefreshToken(userId, tokenId);
expect(context.mockRedisClient.del).toHaveBeenCalledWith(
`auth:refresh_tokens:${userId}:${tokenId}`,
);
});
});
describe('revokeAllRefreshTokens', () => {
let context: Awaited<
ReturnType<typeof createRefreshTokenServiceTestModule>
>;
beforeEach(async () => {
context = await createRefreshTokenServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve revogar todos os refresh tokens do usuário', async () => {
const userId = 123;
const mockKeys = [
'auth:refresh_tokens:123:token-1',
'auth:refresh_tokens:123:token-2',
'auth:refresh_tokens:123:token-3',
];
context.mockRedisClient.keys.mockResolvedValue(mockKeys);
context.mockRedisClient.del.mockResolvedValue(undefined);
await context.service.revokeAllRefreshTokens(userId);
expect(context.mockRedisClient.keys).toHaveBeenCalledWith(
`auth:refresh_tokens:${userId}:*`,
);
expect(context.mockRedisClient.del).toHaveBeenCalledWith(...mockKeys);
});
it('deve retornar sem erro quando não há tokens para revogar', async () => {
const userId = 123;
context.mockRedisClient.keys.mockResolvedValue([]);
await context.service.revokeAllRefreshTokens(userId);
expect(context.mockRedisClient.del).not.toHaveBeenCalled();
});
});
describe('getActiveRefreshTokens', () => {
let context: Awaited<
ReturnType<typeof createRefreshTokenServiceTestModule>
>;
beforeEach(async () => {
context = await createRefreshTokenServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve retornar tokens ativos ordenados por data de criação', async () => {
const userId = 123;
const mockKeys = [
'auth:refresh_tokens:123:token-1',
'auth:refresh_tokens:123:token-2',
];
const now = Date.now();
const token1: RefreshTokenData = {
userId: 123,
tokenId: 'token-1',
expiresAt: now + 1000000,
createdAt: now - 2000,
};
const token2: RefreshTokenData = {
userId: 123,
tokenId: 'token-2',
expiresAt: now + 1000000,
createdAt: now - 1000,
};
context.mockRedisClient.keys.mockResolvedValue(mockKeys);
context.mockRedisClient.get
.mockResolvedValueOnce(token1)
.mockResolvedValueOnce(token2);
const result = await context.service.getActiveRefreshTokens(userId);
expect(result).toHaveLength(2);
expect(result[0].tokenId).toBe('token-2');
expect(result[1].tokenId).toBe('token-1');
});
it('deve filtrar tokens expirados', async () => {
const userId = 123;
const mockKeys = [
'auth:refresh_tokens:123:token-1',
'auth:refresh_tokens:123:token-2',
];
const now = Date.now();
const token1: RefreshTokenData = {
userId: 123,
tokenId: 'token-1',
expiresAt: now - 1000,
createdAt: now - 2000,
};
const token2: RefreshTokenData = {
userId: 123,
tokenId: 'token-2',
expiresAt: now + 1000000,
createdAt: now - 1000,
};
context.mockRedisClient.keys.mockResolvedValue(mockKeys);
context.mockRedisClient.get
.mockResolvedValueOnce(token1)
.mockResolvedValueOnce(token2);
const result = await context.service.getActiveRefreshTokens(userId);
expect(result).toHaveLength(1);
expect(result[0].tokenId).toBe('token-2');
});
it('deve retornar array vazio quando não há tokens', async () => {
const userId = 123;
context.mockRedisClient.keys.mockResolvedValue([]);
const result = await context.service.getActiveRefreshTokens(userId);
expect(result).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,62 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenBlacklistService } from '../token-blacklist.service';
import { IRedisClient } from '../../../core/configs/cache/IRedisClient';
import { RedisClientToken } from '../../../core/configs/cache/redis-client.adapter.provider';
import { JwtService } from '@nestjs/jwt';
export const createMockRedisClient = () =>
({
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
keys: jest.fn(),
} as any);
export const createMockJwtService = () =>
({
decode: jest.fn(),
} as any);
export interface TokenBlacklistServiceTestContext {
service: TokenBlacklistService;
mockRedisClient: jest.Mocked<IRedisClient>;
mockJwtService: jest.Mocked<JwtService>;
}
export async function createTokenBlacklistServiceTestModule(
redisClientMethods: Partial<IRedisClient> = {},
jwtServiceMethods: Partial<JwtService> = {},
): Promise<TokenBlacklistServiceTestContext> {
const mockRedisClient = {
...createMockRedisClient(),
...redisClientMethods,
} as any;
const mockJwtService = {
...createMockJwtService(),
...jwtServiceMethods,
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
TokenBlacklistService,
{
provide: RedisClientToken,
useValue: mockRedisClient,
},
{
provide: JwtService,
useValue: mockJwtService,
},
],
}).compile();
const service = module.get<TokenBlacklistService>(TokenBlacklistService);
return {
service,
mockRedisClient,
mockJwtService,
};
}

View File

@@ -0,0 +1,257 @@
import { createTokenBlacklistServiceTestModule } from './token-blacklist.service.spec.helper';
import { JwtPayload } from '../../models/jwt-payload.model';
describe('TokenBlacklistService', () => {
describe('addToBlacklist', () => {
let context: Awaited<
ReturnType<typeof createTokenBlacklistServiceTestModule>
>;
beforeEach(async () => {
context = await createTokenBlacklistServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve adicionar token à blacklist com sucesso', async () => {
const mockToken = 'valid.jwt.token';
const mockPayload: JwtPayload = {
id: 123,
sellerId: 1,
storeId: '1',
username: 'user',
email: 'user@example.com',
exp: Math.floor(Date.now() / 1000) + 3600,
};
context.mockJwtService.decode.mockReturnValue(mockPayload);
context.mockRedisClient.set.mockResolvedValue(undefined);
await context.service.addToBlacklist(mockToken);
expect(context.mockJwtService.decode).toHaveBeenCalledWith(mockToken);
expect(context.mockRedisClient.set).toHaveBeenCalled();
});
it('deve adicionar token à blacklist com TTL customizado', async () => {
const mockToken = 'valid.jwt.token';
const mockPayload: JwtPayload = {
id: 123,
sellerId: 1,
storeId: '1',
username: 'user',
email: 'user@example.com',
exp: Math.floor(Date.now() / 1000) + 3600,
};
const customTTL = 7200;
context.mockJwtService.decode.mockReturnValue(mockPayload);
context.mockRedisClient.set.mockResolvedValue(undefined);
await context.service.addToBlacklist(mockToken, customTTL);
expect(context.mockRedisClient.set).toHaveBeenCalledWith(
expect.any(String),
'blacklisted',
customTTL,
);
});
it('deve calcular TTL automaticamente quando não informado', async () => {
const mockToken = 'valid.jwt.token';
const now = Math.floor(Date.now() / 1000);
const exp = now + 3600;
const mockPayload: JwtPayload = {
id: 123,
sellerId: 1,
storeId: '1',
username: 'user',
email: 'user@example.com',
exp,
};
context.mockJwtService.decode.mockReturnValue(mockPayload);
context.mockRedisClient.set.mockResolvedValue(undefined);
await context.service.addToBlacklist(mockToken);
expect(context.mockRedisClient.set).toHaveBeenCalledWith(
expect.any(String),
'blacklisted',
expect.any(Number),
);
});
it('deve lançar erro quando token é inválido', async () => {
const mockToken = 'invalid.token';
context.mockJwtService.decode.mockReturnValue(null);
await expect(
context.service.addToBlacklist(mockToken),
).rejects.toThrow('Token inválido');
});
it('deve lançar erro quando decode falha', async () => {
const mockToken = 'invalid.token';
context.mockJwtService.decode.mockImplementation(() => {
throw new Error('Token malformado');
});
await expect(
context.service.addToBlacklist(mockToken),
).rejects.toThrow('Erro ao adicionar token à blacklist');
});
});
describe('isBlacklisted', () => {
let context: Awaited<
ReturnType<typeof createTokenBlacklistServiceTestModule>
>;
beforeEach(async () => {
context = await createTokenBlacklistServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve retornar true quando token está na blacklist', async () => {
const mockToken = 'blacklisted.token';
const mockPayload: JwtPayload = {
id: 123,
sellerId: 1,
storeId: '1',
username: 'user',
email: 'user@example.com',
};
context.mockJwtService.decode.mockReturnValue(mockPayload);
context.mockRedisClient.get.mockResolvedValue('blacklisted');
const result = await context.service.isBlacklisted(mockToken);
expect(result).toBe(true);
expect(context.mockRedisClient.get).toHaveBeenCalled();
});
it('deve retornar false quando token não está na blacklist', async () => {
const mockToken = 'valid.token';
const mockPayload: JwtPayload = {
id: 123,
sellerId: 1,
storeId: '1',
username: 'user',
email: 'user@example.com',
};
context.mockJwtService.decode.mockReturnValue(mockPayload);
context.mockRedisClient.get.mockResolvedValue(null);
const result = await context.service.isBlacklisted(mockToken);
expect(result).toBe(false);
});
it('deve retornar false quando ocorre erro', async () => {
const mockToken = 'error.token';
const mockPayload: JwtPayload = {
id: 123,
sellerId: 1,
storeId: '1',
username: 'user',
email: 'user@example.com',
};
context.mockJwtService.decode.mockReturnValue(mockPayload);
context.mockRedisClient.get.mockRejectedValue(
new Error('Redis error'),
);
const result = await context.service.isBlacklisted(mockToken);
expect(result).toBe(false);
});
});
describe('removeFromBlacklist', () => {
let context: Awaited<
ReturnType<typeof createTokenBlacklistServiceTestModule>
>;
beforeEach(async () => {
context = await createTokenBlacklistServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve remover token da blacklist com sucesso', async () => {
const mockToken = 'token.to.remove';
const mockPayload: JwtPayload = {
id: 123,
sellerId: 1,
storeId: '1',
username: 'user',
email: 'user@example.com',
};
context.mockJwtService.decode.mockReturnValue(mockPayload);
context.mockRedisClient.del.mockResolvedValue(undefined);
await context.service.removeFromBlacklist(mockToken);
expect(context.mockRedisClient.del).toHaveBeenCalled();
});
});
describe('clearUserBlacklist', () => {
let context: Awaited<
ReturnType<typeof createTokenBlacklistServiceTestModule>
>;
beforeEach(async () => {
context = await createTokenBlacklistServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve limpar todos os tokens do usuário da blacklist', async () => {
const userId = 123;
const mockKeys = [
'auth:blacklist:123:hash1',
'auth:blacklist:123:hash2',
'auth:blacklist:123:hash3',
];
context.mockRedisClient.keys.mockResolvedValue(mockKeys);
context.mockRedisClient.del.mockResolvedValue(undefined);
await context.service.clearUserBlacklist(userId);
expect(context.mockRedisClient.keys).toHaveBeenCalledWith(
`auth:blacklist:${userId}:*`,
);
expect(context.mockRedisClient.del).toHaveBeenCalledWith(...mockKeys);
});
it('deve retornar sem erro quando não há tokens para limpar', async () => {
const userId = 123;
context.mockRedisClient.keys.mockResolvedValue([]);
await context.service.clearUserBlacklist(userId);
expect(context.mockRedisClient.del).not.toHaveBeenCalled();
});
});
});

View File

@@ -31,11 +31,11 @@ export class LoginAuditService {
private readonly LOG_PREFIX = 'login_audit';
private readonly LOG_EXPIRY = 30 * 24 * 60 * 60;
constructor(
@Inject('REDIS_CLIENT') private readonly redis: Redis,
) {}
constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
async logLoginAttempt(log: Omit<LoginAuditLog, 'id' | 'timestamp'>): Promise<void> {
async logLoginAttempt(
log: Omit<LoginAuditLog, 'id' | 'timestamp'>,
): Promise<void> {
const logId = this.generateLogId();
const timestamp = DateUtil.now();
@@ -69,7 +69,9 @@ export class LoginAuditService {
await this.redis.expire(dateLogsKey, this.LOG_EXPIRY);
}
async getLoginLogs(filters: LoginAuditFilters = {}): Promise<LoginAuditLog[]> {
async getLoginLogs(
filters: LoginAuditFilters = {},
): Promise<LoginAuditLog[]> {
const logIds = await this.getLogIds(filters);
const logs: LoginAuditLog[] = [];
@@ -102,13 +104,21 @@ export class LoginAuditService {
return logs.slice(offset, offset + limit);
}
async getLoginStats(userId?: number, days: number = 7): Promise<{
async getLoginStats(
userId?: number,
days: number = 7,
): Promise<{
totalAttempts: number;
successfulLogins: number;
failedLogins: number;
uniqueIps: number;
topIps: Array<{ ip: string; count: number }>;
dailyStats: Array<{ date: string; attempts: number; successes: number; failures: number }>;
dailyStats: Array<{
date: string;
attempts: number;
successes: number;
failures: number;
}>;
}> {
const endDate = DateUtil.now();
const startDate = new Date(endDate.getTime() - days * 24 * 60 * 60 * 1000);
@@ -127,15 +137,20 @@ export class LoginAuditService {
const stats = {
totalAttempts: logs.length,
successfulLogins: logs.filter(log => log.success).length,
failedLogins: logs.filter(log => !log.success).length,
uniqueIps: new Set(logs.map(log => log.ipAddress)).size,
successfulLogins: logs.filter((log) => log.success).length,
failedLogins: logs.filter((log) => !log.success).length,
uniqueIps: new Set(logs.map((log) => log.ipAddress)).size,
topIps: [] as Array<{ ip: string; count: number }>,
dailyStats: [] as Array<{ date: string; attempts: number; successes: number; failures: number }>,
dailyStats: [] as Array<{
date: string;
attempts: number;
successes: number;
failures: number;
}>,
};
const ipCounts = new Map<string, number>();
logs.forEach(log => {
logs.forEach((log) => {
ipCounts.set(log.ipAddress, (ipCounts.get(log.ipAddress) || 0) + 1);
});
@@ -144,10 +159,17 @@ export class LoginAuditService {
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const dailyCounts = new Map<string, { attempts: number; successes: number; failures: number }>();
logs.forEach(log => {
const dailyCounts = new Map<
string,
{ attempts: number; successes: number; failures: number }
>();
logs.forEach((log) => {
const date = DateUtil.toBrazilString(log.timestamp, 'yyyy-MM-dd');
const dayStats = dailyCounts.get(date) || { attempts: 0, successes: 0, failures: 0 };
const dayStats = dailyCounts.get(date) || {
attempts: 0,
successes: 0,
failures: 0,
};
dayStats.attempts++;
if (log.success) {
@@ -168,7 +190,9 @@ export class LoginAuditService {
}
async cleanupOldLogs(): Promise<void> {
const cutoffDate = new Date(DateUtil.nowTimestamp() - 30 * 24 * 60 * 60 * 1000);
const cutoffDate = new Date(
DateUtil.nowTimestamp() - 30 * 24 * 60 * 60 * 1000,
);
const cutoffDateStr = DateUtil.toBrazilString(cutoffDate, 'yyyy-MM-dd');
const oldDates = this.getDateRange(new Date('2020-01-01'), cutoffDate);
@@ -190,7 +214,9 @@ export class LoginAuditService {
}
if (filters.startDate || filters.endDate) {
const startDate = filters.startDate || new Date(DateUtil.nowTimestamp() - 7 * 24 * 60 * 60 * 1000);
const startDate =
filters.startDate ||
new Date(DateUtil.nowTimestamp() - 7 * 24 * 60 * 60 * 1000);
const endDate = filters.endDate || DateUtil.now();
const dates = this.getDateRange(startDate, endDate);
@@ -210,7 +236,9 @@ export class LoginAuditService {
}
private generateLogId(): string {
return `${DateUtil.nowTimestamp()}_${Math.random().toString(36).substr(2, 9)}`;
return `${DateUtil.nowTimestamp()}_${Math.random()
.toString(36)
.substr(2, 9)}`;
}
private buildLogKey(logId: string): string {
@@ -233,8 +261,14 @@ export class LoginAuditService {
return `${this.LOG_PREFIX}:date:${date}`;
}
private matchesFilters(log: LoginAuditLog, filters: LoginAuditFilters): boolean {
if (filters.username && !log.username.toLowerCase().includes(filters.username.toLowerCase())) {
private matchesFilters(
log: LoginAuditLog,
filters: LoginAuditFilters,
): boolean {
if (
filters.username &&
!log.username.toLowerCase().includes(filters.username.toLowerCase())
) {
return false;
}

View File

@@ -16,11 +16,12 @@ export class RateLimitingService {
blockDurationMs: 1 * 60 * 1000,
};
constructor(
@Inject(RedisClientToken) private readonly redis: IRedisClient,
) {}
constructor(@Inject(RedisClientToken) private readonly redis: IRedisClient) {}
async isAllowed(ip: string, config?: Partial<RateLimitConfig>): Promise<boolean> {
async isAllowed(
ip: string,
config?: Partial<RateLimitConfig>,
): Promise<boolean> {
const finalConfig = { ...this.defaultConfig, ...config };
const key = this.buildAttemptKey(ip);
const blockKey = this.buildBlockKey(ip);
@@ -51,21 +52,25 @@ export class RateLimitingService {
return {attempts, 0}
`;
const result = await this.redis.eval(
const result = (await this.redis.eval(
luaScript,
2,
key,
blockKey,
finalConfig.maxAttempts,
finalConfig.windowMs,
finalConfig.blockDurationMs
) as [number, number];
finalConfig.blockDurationMs,
)) as [number, number];
const [attempts, isBlockedResult] = result;
return isBlockedResult === 0;
}
async recordAttempt(ip: string, success: boolean, config?: Partial<RateLimitConfig>): Promise<void> {
async recordAttempt(
ip: string,
success: boolean,
config?: Partial<RateLimitConfig>,
): Promise<void> {
const finalConfig = { ...this.defaultConfig, ...config };
const key = this.buildAttemptKey(ip);
const blockKey = this.buildBlockKey(ip);

View File

@@ -24,18 +24,21 @@ export class RefreshTokenService {
private readonly jwtService: JwtService,
) {}
async generateRefreshToken(userId: number, sessionId?: string): Promise<string> {
async generateRefreshToken(
userId: number,
sessionId?: string,
): Promise<string> {
const tokenId = randomBytes(32).toString('hex');
const refreshToken = this.jwtService.sign(
{ userId, tokenId, sessionId, type: 'refresh' },
{ expiresIn: '7d' }
{ expiresIn: '7d' },
);
const tokenData: RefreshTokenData = {
userId,
tokenId,
sessionId,
expiresAt: DateUtil.nowTimestamp() + (this.REFRESH_TOKEN_TTL * 1000),
expiresAt: DateUtil.nowTimestamp() + this.REFRESH_TOKEN_TTL * 1000,
createdAt: DateUtil.nowTimestamp(),
};
@@ -75,7 +78,7 @@ export class RefreshTokenService {
username: '',
email: '',
sessionId: sessionId || tokenData.sessionId,
tokenId
tokenId,
} as JwtPayload;
} catch (error) {
throw new UnauthorizedException('Refresh token inválido');
@@ -118,7 +121,7 @@ export class RefreshTokenService {
if (activeTokens.length > this.MAX_REFRESH_TOKENS_PER_USER) {
const tokensToRemove = activeTokens
.slice(this.MAX_REFRESH_TOKENS_PER_USER)
.map(token => token.tokenId);
.map((token) => token.tokenId);
for (const tokenId of tokensToRemove) {
await this.revokeRefreshToken(userId, tokenId);

View File

@@ -19,11 +19,13 @@ export class SessionManagementService {
private readonly SESSION_TTL = 8 * 60 * 60;
private readonly MAX_SESSIONS_PER_USER = 1;
constructor(
@Inject(RedisClientToken) private readonly redis: IRedisClient,
) {}
constructor(@Inject(RedisClientToken) private readonly redis: IRedisClient) {}
async createSession(userId: number, ipAddress: string, userAgent: string): Promise<SessionData> {
async createSession(
userId: number,
ipAddress: string,
userAgent: string,
): Promise<SessionData> {
const sessionId = randomBytes(16).toString('hex');
const now = DateUtil.nowTimestamp();
@@ -45,7 +47,10 @@ export class SessionManagementService {
return sessionData;
}
async updateSessionActivity(userId: number, sessionId: string): Promise<void> {
async updateSessionActivity(
userId: number,
sessionId: string,
): Promise<void> {
const key = this.buildSessionKey(userId, sessionId);
const sessionData = await this.redis.get<SessionData>(key);
@@ -55,7 +60,10 @@ export class SessionManagementService {
}
}
async getActiveSessions(userId: number, currentSessionId?: string): Promise<SessionData[]> {
async getActiveSessions(
userId: number,
currentSessionId?: string,
): Promise<SessionData[]> {
const pattern = this.buildSessionPattern(userId);
const keys = await this.redis.keys(pattern);
@@ -99,7 +107,10 @@ export class SessionManagementService {
}
}
async terminateOtherSessions(userId: number, currentSessionId: string): Promise<void> {
async terminateOtherSessions(
userId: number,
currentSessionId: string,
): Promise<void> {
const pattern = this.buildSessionPattern(userId);
const keys = await this.redis.keys(pattern);
@@ -130,7 +141,7 @@ export class SessionManagementService {
if (activeSessions.length > this.MAX_SESSIONS_PER_USER) {
const sessionsToRemove = activeSessions
.slice(this.MAX_SESSIONS_PER_USER)
.map(session => session.sessionId);
.map((session) => session.sessionId);
for (const sessionId of sessionsToRemove) {
await this.terminateSession(userId, sessionId);

View File

@@ -59,12 +59,16 @@ export class TokenBlacklistService {
private calculateTokenTTL(payload: JwtPayload): number {
const now = Math.floor(Date.now() / 1000);
const exp = payload.exp || (now + 8 * 60 * 60);
const exp = payload.exp || now + 8 * 60 * 60;
return Math.max(0, exp - now);
}
private hashToken(token: string): string {
const crypto = require('crypto');
return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16);
return crypto
.createHash('sha256')
.update(token)
.digest('hex')
.substring(0, 16);
}
}

View File

@@ -31,7 +31,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
const token = req.headers?.authorization?.replace('Bearer ', '');
if (token && await this.tokenBlacklistService.isBlacklisted(token)) {
if (token && (await this.tokenBlacklistService.isBlacklisted(token))) {
throw new UnauthorizedException('Token foi invalidado');
}
@@ -39,9 +39,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const cachedUser = await this.redis.get<any>(sessionKey);
if (cachedUser) {
const isSessionActive = await this.sessionManagementService.isSessionActive(
const isSessionActive =
await this.sessionManagementService.isSessionActive(
payload.id,
payload.sessionId
payload.sessionId,
);
if (!isSessionActive) {
@@ -65,7 +66,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
if (user.situacao === 'B') {
throw new UnauthorizedException('Usuário bloqueado, acesso não permitido');
throw new UnauthorizedException(
'Usuário bloqueado, acesso não permitido',
);
}
const userData = {

View File

@@ -19,7 +19,10 @@ export class ResetPasswordService {
if (!user) return null;
const newPassword = Guid.create().toString().substring(0, 8);
await this.userRepository.updatePassword(user.sellerId, md5(newPassword).toUpperCase());
await this.userRepository.updatePassword(
user.sellerId,
md5(newPassword).toUpperCase(),
);
await this.emailService.sendPasswordReset(user.email, newPassword);

View File

@@ -8,11 +8,8 @@ import { EmailService } from './email.service';
import { AuthenticateUserHandler } from '../auth/commands/authenticate-user.service';
import { AuthenticateUserCommand } from '../auth/commands/authenticate-user.command';
@Module({
imports: [
TypeOrmModule.forFeature([]),
],
imports: [TypeOrmModule.forFeature([])],
providers: [
UsersService,
UserRepository,

View File

@@ -4,8 +4,6 @@ import { ResetPasswordService } from './reset-password.service';
import { ChangePasswordService } from './change-password.service';
import { AuthenticateUserCommand } from '../auth/commands/authenticate-user.command';
@Injectable()
export class UsersService {
constructor(
@@ -22,7 +20,15 @@ export class UsersService {
return this.resetPasswordService.execute(user.document, user.email);
}
async changePassword(user: { id: number; password: string; newPassword: string }) {
return this.changePasswordService.execute(user.id, user.password, user.newPassword);
async changePassword(user: {
id: number;
password: string;
newPassword: string;
}) {
return this.changePasswordService.execute(
user.id,
user.password,
user.newPassword,
);
}
}

View File

@@ -7,7 +7,8 @@ import { ConfigService } from '@nestjs/config';
export class RateLimiterMiddleware implements NestMiddleware {
private readonly ttl: number;
private readonly limit: number;
private readonly store: Map<string, { count: number; expiration: number }> = new Map();
private readonly store: Map<string, { count: number; expiration: number }> =
new Map();
constructor(private configService: ConfigService) {
this.ttl = this.configService.get<number>('THROTTLE_TTL', 60);
@@ -42,7 +43,9 @@ export class RateLimiterMiddleware implements NestMiddleware {
const timeToWait = Math.ceil((record.expiration - now) / 1000);
this.setRateLimitHeaders(res, record.count);
res.header('Retry-After', String(timeToWait));
throw new ThrottlerException(`Too Many Requests. Retry after ${timeToWait} seconds.`);
throw new ThrottlerException(
`Too Many Requests. Retry after ${timeToWait} seconds.`,
);
}
record.count++;
@@ -52,13 +55,17 @@ export class RateLimiterMiddleware implements NestMiddleware {
private generateKey(req: Request): string {
// Combina IP com rota para rate limiting mais preciso
const ip = req.ip || req.headers['x-forwarded-for'] as string || 'unknown-ip';
const ip =
req.ip || (req.headers['x-forwarded-for'] as string) || 'unknown-ip';
const path = req.path || req.originalUrl || '';
return `${ip}:${path}`;
}
private setRateLimitHeaders(res: Response, count: number): void {
res.header('X-RateLimit-Limit', String(this.limit));
res.header('X-RateLimit-Remaining', String(Math.max(0, this.limit - count)));
res.header(
'X-RateLimit-Remaining',
String(Math.max(0, this.limit - count)),
);
}
}

View File

@@ -20,7 +20,7 @@ export class RequestSanitizerMiddleware implements NestMiddleware {
}
private sanitizeObject(obj: any) {
Object.keys(obj).forEach(key => {
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === 'string') {
obj[key] = this.sanitizeString(obj[key]);
} else if (typeof obj[key] === 'object' && obj[key] !== null) {

View File

@@ -9,8 +9,13 @@ import {
import { ResultModel } from '../shared/ResultModel';
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, ResultModel<T>> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<ResultModel<T>> {
export class ResponseInterceptor<T>
implements NestInterceptor<T, ResultModel<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ResultModel<T>> {
return next.handle().pipe(
map((data) => {
return ResultModel.success(data);
@@ -18,4 +23,3 @@ import {
);
}
}

View File

@@ -1,8 +1,12 @@
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
// Decorator para sanitizar strings e prevenir SQL/NoSQL injection
export function IsSanitized(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isSanitized',
target: object.constructor,
@@ -12,19 +16,22 @@ export function IsSanitized(validationOptions?: ValidationOptions) {
validate(value: any, args: ValidationArguments) {
if (typeof value !== 'string') return true; // Skip non-string values
const sqlInjectionRegex = /('|"|;|--|\/\*|\*\/|@@|@|char|nchar|varchar|nvarchar|alter|begin|cast|create|cursor|declare|delete|drop|end|exec|execute|fetch|insert|kill|open|select|sys|sysobjects|syscolumns|table|update|xp_)/i;
const sqlInjectionRegex =
/('|"|;|--|\/\*|\*\/|@@|@|char|nchar|varchar|nvarchar|alter|begin|cast|create|cursor|declare|delete|drop|end|exec|execute|fetch|insert|kill|open|select|sys|sysobjects|syscolumns|table|update|xp_)/i;
if (sqlInjectionRegex.test(value)) {
return false;
}
// Check for NoSQL injection patterns (MongoDB)
const noSqlInjectionRegex = /(\$where|\$ne|\$gt|\$lt|\$gte|\$lte|\$in|\$nin|\$or|\$and|\$regex|\$options|\$elemMatch|\{.*\:.*\})/i;
const noSqlInjectionRegex =
/(\$where|\$ne|\$gt|\$lt|\$gte|\$lte|\$in|\$nin|\$or|\$and|\$regex|\$options|\$elemMatch|\{.*\:.*\})/i;
if (noSqlInjectionRegex.test(value)) {
return false;
}
// Check for XSS attempts
const xssRegex = /(<script|javascript:|on\w+\s*=|<%=|<img|<iframe|alert\(|window\.|document\.)/i;
const xssRegex =
/(<script|javascript:|on\w+\s*=|<%=|<img|<iframe|alert\(|window\.|document\.)/i;
if (xssRegex.test(value)) {
return false;
}
@@ -41,7 +48,7 @@ export function IsSanitized(validationOptions?: ValidationOptions) {
// Decorator para validar IDs seguros (evita injeção em IDs)
export function IsSecureId(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isSecureId',
target: object.constructor,
@@ -49,11 +56,14 @@ export function IsSecureId(validationOptions?: ValidationOptions) {
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
if (typeof value !== 'string' && typeof value !== 'number') return false;
if (typeof value !== 'string' && typeof value !== 'number')
return false;
if (typeof value === 'string') {
// Permitir apenas: letras, números, hífens, underscores e GUIDs
return /^[a-zA-Z0-9\-_]+$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
return /^[a-zA-Z0-9\-_]+$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
value,
);
}
// Se for número, deve ser positivo

View File

@@ -5,6 +5,9 @@ export interface IRedisClient {
del(...keys: string[]): Promise<void>;
keys(pattern: string): Promise<string[]>;
ttl(key: string): Promise<number>;
eval(script: string, numKeys: number, ...keysAndArgs: (string | number)[]): Promise<any>;
eval(
script: string,
numKeys: number,
...keysAndArgs: (string | number)[]
): Promise<any>;
}

View File

@@ -1,4 +1,3 @@
import { RedisClientAdapter } from './redis-client.adapter';
export const RedisClientToken = 'RedisClientInterface';

View File

@@ -6,7 +6,7 @@ import { IRedisClient } from './IRedisClient';
export class RedisClientAdapter implements IRedisClient {
constructor(
@Inject('REDIS_CLIENT')
private readonly redis: Redis
private readonly redis: Redis,
) {}
async get<T>(key: string): Promise<T | null> {
@@ -43,7 +43,11 @@ export class RedisClientAdapter implements IRedisClient {
return this.redis.ttl(key);
}
async eval(script: string, numKeys: number, ...keysAndArgs: (string | number)[]): Promise<any> {
async eval(
script: string,
numKeys: number,
...keysAndArgs: (string | number)[]
): Promise<any> {
return this.redis.eval(script, numKeys, ...keysAndArgs);
}
}

View File

@@ -2,8 +2,6 @@ import { DataSourceOptions } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as oracledb from 'oracledb';
oracledb.initOracleClient({ libDir: process.env.ORACLE_CLIENT_LIB_DIR });
// Definir a estratégia de pool padrão para Oracle
@@ -12,19 +10,22 @@ oracledb.queueTimeout = 60000; // timeout da fila em milissegundos
oracledb.poolIncrement = 1; // incremental de conexões
export function createOracleConfig(config: ConfigService): DataSourceOptions {
const poolMin = parseInt(config.get('ORACLE_POOL_MIN', '5'));
const poolMax = parseInt(config.get('ORACLE_POOL_MAX', '20'));
const poolIncrement = parseInt(config.get('ORACLE_POOL_INCREMENT', '5'));
const poolTimeout = parseInt(config.get('ORACLE_POOL_TIMEOUT', '30000'));
const idleTimeout = parseInt(config.get('ORACLE_POOL_IDLE_TIMEOUT', '300000'));
const idleTimeout = parseInt(
config.get('ORACLE_POOL_IDLE_TIMEOUT', '300000'),
);
const validPoolMin = Math.max(1, poolMin);
const validPoolMax = Math.max(validPoolMin + 1, poolMax);
const validPoolIncrement = Math.max(1, poolIncrement);
if (validPoolMax <= validPoolMin) {
console.warn('Warning: poolMax deve ser maior que poolMin. Ajustando poolMax para poolMin + 1');
console.warn(
'Warning: poolMax deve ser maior que poolMin. Ajustando poolMax para poolMin + 1',
);
}
const options: DataSourceOptions = {

View File

@@ -5,9 +5,15 @@ export function createPostgresConfig(config: ConfigService): DataSourceOptions {
// Obter configurações de ambiente ou usar valores padrão
const poolMin = parseInt(config.get('POSTGRES_POOL_MIN', '5'));
const poolMax = parseInt(config.get('POSTGRES_POOL_MAX', '20'));
const idleTimeout = parseInt(config.get('POSTGRES_POOL_IDLE_TIMEOUT', '30000'));
const connectionTimeout = parseInt(config.get('POSTGRES_POOL_CONNECTION_TIMEOUT', '5000'));
const acquireTimeout = parseInt(config.get('POSTGRES_POOL_ACQUIRE_TIMEOUT', '60000'));
const idleTimeout = parseInt(
config.get('POSTGRES_POOL_IDLE_TIMEOUT', '30000'),
);
const connectionTimeout = parseInt(
config.get('POSTGRES_POOL_CONNECTION_TIMEOUT', '5000'),
);
const acquireTimeout = parseInt(
config.get('POSTGRES_POOL_ACQUIRE_TIMEOUT', '60000'),
);
// Validação de valores mínimos
const validPoolMin = Math.max(1, poolMin);
@@ -25,7 +31,10 @@ export function createPostgresConfig(config: ConfigService): DataSourceOptions {
database: config.get('POSTGRES_DB'),
synchronize: config.get('NODE_ENV') === 'development',
entities: [__dirname + '/../**/*.entity.{ts,js}'],
ssl: config.get('NODE_ENV') === 'production' ? { rejectUnauthorized: false } : false,
ssl:
config.get('NODE_ENV') === 'production'
? { rejectUnauthorized: false }
: false,
logging: config.get('NODE_ENV') === 'development',
poolSize: validPoolMax, // máximo de conexões no pool
extra: {

View File

@@ -1,11 +0,0 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/controllers#controllers
*/
import { Controller } from '@nestjs/common';
@Controller()
export class NegotiationsController { }

View File

@@ -1,19 +0,0 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/modules
*/
import { Module } from '@nestjs/common';
import { NegotiationsController } from './negotiations.controller';
import { NegotiationsService } from './negotiations.service';
@Module({
imports: [],
controllers: [
NegotiationsController,],
providers: [
NegotiationsService,],
})
export class NegotiationsModule { }

View File

@@ -1,11 +0,0 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/providers#services
*/
import { Injectable } from '@nestjs/common';
@Injectable()
export class NegotiationsService { }

View File

@@ -1,17 +0,0 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/modules
*/
import { Module } from '@nestjs/common';
import { OccurrencesService } from './occurrences.service';
@Module({
imports: [],
controllers: [],
providers: [
OccurrencesService,],
})
export class OccurrencesModule { }

View File

@@ -1,10 +0,0 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/providers#services
*/
import { Injectable } from '@nestjs/common';
@Injectable()
export class OccurrencesService { }

View File

@@ -1,10 +0,0 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/controllers#controllers
*/
import { Controller } from '@nestjs/common';
@Controller()
export class OcorrencesController { }

View File

@@ -1,37 +0,0 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/controllers#controllers
*/
import { Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
@ApiTags('CRM - Reason Table')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('api/v1/crm/reason')
export class ReasonTableController {
@Get()
async getReasons() {
return null;
}
@Post()
async createReasons() {
return null;
}
@Put('/:id')
async updateReasons(@Param('id') id: number) {
return null;
}
@Delete('/:id')
async deleteReasons(@Param('id') id: number) {
return null;
}
}

View File

@@ -1,19 +0,0 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/modules
*/
import { Module } from '@nestjs/common';
import { ReasonTableController } from './reason-table.controller';
import { ReasonTableService } from './reason-table.service';
@Module({
imports: [],
controllers: [
ReasonTableController,],
providers: [
ReasonTableService,],
})
export class ReasonTableModule { }

View File

@@ -1,10 +0,0 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars *//*
https://docs.nestjs.com/providers#services
*/
import { Injectable } from '@nestjs/common';
@Injectable()
export class ReasonTableService { }

View File

@@ -1,31 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Logger } from '@nestjs/common';
import { DataConsultService } from '../data-consult.service';
import { DataConsultRepository } from '../data-consult.repository';
import { ILogger } from '../../Log/ILogger';
import { IRedisClient } from '../../core/configs/cache/IRedisClient';
import { RedisClientToken } from '../../core/configs/cache/redis-client.adapter.provider';
import { DataSource } from 'typeorm';
import { DATA_SOURCE } from '../../core/constants';
export const createMockRepository = (methods: Partial<DataConsultRepository> = {}) => ({
export const createMockRepository = (
methods: Partial<DataConsultRepository> = {},
) =>
({
findStores: jest.fn(),
findSellers: jest.fn(),
findBillings: jest.fn(),
findCustomers: jest.fn(),
findProducts: jest.fn(),
findProductsByCodauxiliar: jest.fn(),
findAllProducts: jest.fn(),
findAllCarriers: jest.fn(),
findRegions: jest.fn(),
...methods,
} as any);
export const createMockLogger = () => ({
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
} as any);
export const createMockRedisClient = () => ({
export const createMockRedisClient = () =>
({
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
} as any);
@@ -33,18 +32,22 @@ export const createMockRedisClient = () => ({
export interface DataConsultServiceTestContext {
service: DataConsultService;
mockRepository: jest.Mocked<DataConsultRepository>;
mockLogger: jest.Mocked<ILogger>;
mockRedisClient: jest.Mocked<IRedisClient>;
mockDataSource: jest.Mocked<DataSource>;
mockLogger: {
error: jest.Mock;
};
}
export async function createDataConsultServiceTestModule(
repositoryMethods: Partial<DataConsultRepository> = {},
redisClientMethods: Partial<IRedisClient> = {}
redisClientMethods: Partial<IRedisClient> = {},
): Promise<DataConsultServiceTestContext> {
const mockRepository = createMockRepository(repositoryMethods);
const mockLogger = createMockLogger();
const mockRedisClient = { ...createMockRedisClient(), ...redisClientMethods } as any;
const mockRedisClient = {
...createMockRedisClient(),
...redisClientMethods,
} as any;
const mockDataSource = {} as any;
const module: TestingModule = await Test.createTestingModule({
@@ -58,10 +61,6 @@ export async function createDataConsultServiceTestModule(
provide: RedisClientToken,
useValue: mockRedisClient,
},
{
provide: 'LoggerService',
useValue: mockLogger,
},
{
provide: DATA_SOURCE,
useValue: mockDataSource,
@@ -71,12 +70,21 @@ export async function createDataConsultServiceTestModule(
const service = module.get<DataConsultService>(DataConsultService);
const mockLogger = {
error: jest.fn(),
};
jest.spyOn(Logger.prototype, 'error').mockImplementation(
(message: any, ...optionalParams: any[]) => {
mockLogger.error(message, ...optionalParams);
},
);
return {
service,
mockRepository,
mockLogger,
mockRedisClient,
mockDataSource,
mockLogger,
};
}

View File

@@ -23,7 +23,7 @@ describe('DataConsultService', () => {
const result = await context.service.stores();
result.forEach(store => {
result.forEach((store) => {
expect(store.id).toBeDefined();
expect(store.name).toBeDefined();
expect(store.store).toBeDefined();
@@ -36,7 +36,10 @@ describe('DataConsultService', () => {
});
it('should validate that repository result is an array', async () => {
context.mockRepository.findStores.mockResolvedValue({ id: '001', name: 'Loja 1' } as any);
context.mockRepository.findStores.mockResolvedValue({
id: '001',
name: 'Loja 1',
} as any);
const result = await context.service.stores();
expect(Array.isArray(result)).toBe(true);
});
@@ -49,7 +52,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.stores();
result.forEach(store => {
result.forEach((store) => {
expect(store.id).not.toBe('');
expect(store.name).not.toBe('');
expect(store.store).not.toBe('');
@@ -60,7 +63,10 @@ describe('DataConsultService', () => {
const repositoryError = new Error('Database connection failed');
context.mockRepository.findStores.mockRejectedValue(repositoryError);
await expect(context.service.stores()).rejects.toThrow(HttpException);
expect(context.mockLogger.error).toHaveBeenCalledWith('Erro ao buscar lojas', repositoryError);
expect(context.mockLogger.error).toHaveBeenCalledWith(
'Erro ao buscar lojas',
repositoryError,
);
});
});
});
@@ -85,7 +91,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.sellers();
result.forEach(seller => {
result.forEach((seller) => {
expect(seller.id).toBeDefined();
expect(seller.name).toBeDefined();
});
@@ -97,7 +103,10 @@ describe('DataConsultService', () => {
});
it('should validate that repository result is an array', async () => {
context.mockRepository.findSellers.mockResolvedValue({ id: '001', name: 'Vendedor 1' } as any);
context.mockRepository.findSellers.mockResolvedValue({
id: '001',
name: 'Vendedor 1',
} as any);
const result = await context.service.sellers();
expect(Array.isArray(result)).toBe(true);
});
@@ -109,17 +118,37 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.sellers();
result.forEach(seller => {
result.forEach((seller) => {
expect(seller.id).not.toBe('');
expect(seller.name).not.toBe('');
});
});
it('should filter out sellers with null id', async () => {
context.mockRepository.findSellers.mockResolvedValue([
{ id: null, name: 'Vendedor 1' },
{ id: '002', name: 'Vendedor 2' },
{ id: null, name: 'Vendedor 3' },
] as any);
const result = await context.service.sellers();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('002');
expect(result[0].name).toBe('Vendedor 2');
result.forEach((seller) => {
expect(seller.id).not.toBeNull();
expect(seller.id).toBeDefined();
});
});
it('should log error when repository throws exception', async () => {
const repositoryError = new Error('Database connection failed');
context.mockRepository.findSellers.mockRejectedValue(repositoryError);
await expect(context.service.sellers()).rejects.toThrow(HttpException);
expect(context.mockLogger.error).toHaveBeenCalledWith('Erro ao buscar vendedores', repositoryError);
expect(context.mockLogger.error).toHaveBeenCalledWith(
'Erro ao buscar vendedores',
repositoryError,
);
});
});
});
@@ -144,7 +173,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.billings();
result.forEach(billing => {
result.forEach((billing) => {
expect(billing.id).toBeDefined();
expect(billing.date).toBeDefined();
expect(billing.total).toBeDefined();
@@ -157,7 +186,11 @@ describe('DataConsultService', () => {
});
it('should validate that repository result is an array', async () => {
context.mockRepository.findBillings.mockResolvedValue({ id: '001', date: new Date(), total: 1000 } as any);
context.mockRepository.findBillings.mockResolvedValue({
id: '001',
date: new Date(),
total: 1000,
} as any);
const result = await context.service.billings();
expect(Array.isArray(result)).toBe(true);
});
@@ -170,7 +203,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.billings();
result.forEach(billing => {
result.forEach((billing) => {
expect(billing.id).not.toBe('');
expect(billing.date).toBeDefined();
expect(billing.total).toBeDefined();
@@ -181,7 +214,10 @@ describe('DataConsultService', () => {
const repositoryError = new Error('Database connection failed');
context.mockRepository.findBillings.mockRejectedValue(repositoryError);
await expect(context.service.billings()).rejects.toThrow(HttpException);
expect(context.mockLogger.error).toHaveBeenCalledWith('Erro ao buscar faturamento', repositoryError);
expect(context.mockLogger.error).toHaveBeenCalledWith(
'Erro ao buscar faturamento',
repositoryError,
);
});
});
});
@@ -206,7 +242,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.customers('test');
result.forEach(customer => {
result.forEach((customer) => {
expect(customer.id).toBeDefined();
expect(customer.name).toBeDefined();
expect(customer.document).toBeDefined();
@@ -219,7 +255,11 @@ describe('DataConsultService', () => {
});
it('should validate that repository result is an array', async () => {
context.mockRepository.findCustomers.mockResolvedValue({ id: '001', name: 'Cliente 1', document: '12345678900' } as any);
context.mockRepository.findCustomers.mockResolvedValue({
id: '001',
name: 'Cliente 1',
document: '12345678900',
} as any);
const result = await context.service.customers('test');
expect(Array.isArray(result)).toBe(true);
});
@@ -232,7 +272,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.customers('test');
result.forEach(customer => {
result.forEach((customer) => {
expect(customer.id).not.toBe('');
expect(customer.name).not.toBe('');
expect(customer.document).not.toBe('');
@@ -242,8 +282,13 @@ describe('DataConsultService', () => {
it('should log error when repository throws exception', async () => {
const repositoryError = new Error('Database connection failed');
context.mockRepository.findCustomers.mockRejectedValue(repositoryError);
await expect(context.service.customers('test')).rejects.toThrow(HttpException);
expect(context.mockLogger.error).toHaveBeenCalledWith('Erro ao buscar clientes', repositoryError);
await expect(context.service.customers('test')).rejects.toThrow(
HttpException,
);
expect(context.mockLogger.error).toHaveBeenCalledWith(
'Erro ao buscar clientes',
repositoryError,
);
});
});
});
@@ -268,7 +313,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.getAllProducts();
result.forEach(product => {
result.forEach((product) => {
expect(product.id).toBeDefined();
expect(product.name).toBeDefined();
expect(product.manufacturerCode).toBeDefined();
@@ -281,7 +326,11 @@ describe('DataConsultService', () => {
});
it('should validate that repository result is an array', async () => {
context.mockRepository.findAllProducts.mockResolvedValue({ id: '001', name: 'Produto 1', manufacturerCode: 'FAB001' } as any);
context.mockRepository.findAllProducts.mockResolvedValue({
id: '001',
name: 'Produto 1',
manufacturerCode: 'FAB001',
} as any);
const result = await context.service.getAllProducts();
expect(Array.isArray(result)).toBe(true);
});
@@ -294,7 +343,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.getAllProducts();
result.forEach(product => {
result.forEach((product) => {
expect(product.id).not.toBe('');
expect(product.name).not.toBe('');
expect(product.manufacturerCode).not.toBe('');
@@ -303,9 +352,16 @@ describe('DataConsultService', () => {
it('should log error when repository throws exception', async () => {
const repositoryError = new Error('Database connection failed');
context.mockRepository.findAllProducts.mockRejectedValue(repositoryError);
await expect(context.service.getAllProducts()).rejects.toThrow(HttpException);
expect(context.mockLogger.error).toHaveBeenCalledWith('Erro ao buscar todos os produtos', repositoryError);
context.mockRepository.findAllProducts.mockRejectedValue(
repositoryError,
);
await expect(context.service.getAllProducts()).rejects.toThrow(
HttpException,
);
expect(context.mockLogger.error).toHaveBeenCalledWith(
'Erro ao buscar todos os produtos',
repositoryError,
);
});
});
});
@@ -325,12 +381,19 @@ describe('DataConsultService', () => {
it('should validate that all carriers have required properties (carrierId, carrierName, carrierDescription)', async () => {
context.mockRepository.findAllCarriers.mockResolvedValue([
{ carrierId: '001', carrierName: 'Transportadora 1' },
{ carrierName: 'Transportadora 2', carrierDescription: '002 - Transportadora 2' },
{ carrierId: '003', carrierName: 'Transportadora 3', carrierDescription: '003 - Transportadora 3' },
{
carrierName: 'Transportadora 2',
carrierDescription: '002 - Transportadora 2',
},
{
carrierId: '003',
carrierName: 'Transportadora 3',
carrierDescription: '003 - Transportadora 3',
},
] as any);
const result = await context.service.getAllCarriers();
result.forEach(carrier => {
result.forEach((carrier) => {
expect(carrier.carrierId).toBeDefined();
expect(carrier.carrierName).toBeDefined();
expect(carrier.carrierDescription).toBeDefined();
@@ -343,20 +406,36 @@ describe('DataConsultService', () => {
});
it('should validate that repository result is an array', async () => {
context.mockRepository.findAllCarriers.mockResolvedValue({ carrierId: '001', carrierName: 'Transportadora 1', carrierDescription: '001 - Transportadora 1' } as any);
context.mockRepository.findAllCarriers.mockResolvedValue({
carrierId: '001',
carrierName: 'Transportadora 1',
carrierDescription: '001 - Transportadora 1',
} as any);
const result = await context.service.getAllCarriers();
expect(Array.isArray(result)).toBe(true);
});
it('should validate that required properties are not empty strings', async () => {
context.mockRepository.findAllCarriers.mockResolvedValue([
{ carrierId: '', carrierName: 'Transportadora 1', carrierDescription: '001 - Transportadora 1' },
{ carrierId: '002', carrierName: '', carrierDescription: '002 - Transportadora 2' },
{ carrierId: '003', carrierName: 'Transportadora 3', carrierDescription: '' },
{
carrierId: '',
carrierName: 'Transportadora 1',
carrierDescription: '001 - Transportadora 1',
},
{
carrierId: '002',
carrierName: '',
carrierDescription: '002 - Transportadora 2',
},
{
carrierId: '003',
carrierName: 'Transportadora 3',
carrierDescription: '',
},
] as any);
const result = await context.service.getAllCarriers();
result.forEach(carrier => {
result.forEach((carrier) => {
expect(carrier.carrierId).not.toBe('');
expect(carrier.carrierName).not.toBe('');
expect(carrier.carrierDescription).not.toBe('');
@@ -365,9 +444,16 @@ describe('DataConsultService', () => {
it('should log error when repository throws exception', async () => {
const repositoryError = new Error('Database connection failed');
context.mockRepository.findAllCarriers.mockRejectedValue(repositoryError);
await expect(context.service.getAllCarriers()).rejects.toThrow(HttpException);
expect(context.mockLogger.error).toHaveBeenCalledWith('Erro ao buscar transportadoras', repositoryError);
context.mockRepository.findAllCarriers.mockRejectedValue(
repositoryError,
);
await expect(context.service.getAllCarriers()).rejects.toThrow(
HttpException,
);
expect(context.mockLogger.error).toHaveBeenCalledWith(
'Erro ao buscar transportadoras',
repositoryError,
);
});
});
});
@@ -392,7 +478,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.getRegions();
result.forEach(region => {
result.forEach((region) => {
expect(region.numregiao).toBeDefined();
expect(region.regiao).toBeDefined();
});
@@ -404,7 +490,10 @@ describe('DataConsultService', () => {
});
it('should validate that repository result is an array', async () => {
context.mockRepository.findRegions.mockResolvedValue({ numregiao: 1, regiao: 'Região Sul' } as any);
context.mockRepository.findRegions.mockResolvedValue({
numregiao: 1,
regiao: 'Região Sul',
} as any);
const result = await context.service.getRegions();
expect(Array.isArray(result)).toBe(true);
});
@@ -417,7 +506,7 @@ describe('DataConsultService', () => {
] as any);
const result = await context.service.getRegions();
result.forEach(region => {
result.forEach((region) => {
expect(region.numregiao).toBeDefined();
expect(region.numregiao).not.toBeNull();
expect(region.regiao).toBeDefined();
@@ -428,8 +517,133 @@ describe('DataConsultService', () => {
it('should log error when repository throws exception', async () => {
const repositoryError = new Error('Database connection failed');
context.mockRepository.findRegions.mockRejectedValue(repositoryError);
await expect(context.service.getRegions()).rejects.toThrow(HttpException);
expect(context.mockLogger.error).toHaveBeenCalledWith('Erro ao buscar regiões', repositoryError);
await expect(context.service.getRegions()).rejects.toThrow(
HttpException,
);
expect(context.mockLogger.error).toHaveBeenCalledWith(
'Erro ao buscar regiões',
repositoryError,
);
});
});
});
describe('products', () => {
let context: Awaited<ReturnType<typeof createDataConsultServiceTestModule>>;
beforeEach(async () => {
context = await createDataConsultServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Tests that expose problems', () => {
it('should search products by CODPROD', async () => {
context.mockRepository.findProducts.mockResolvedValue([
{
id: '12345',
name: 'PRODUTO EXEMPLO',
manufacturerCode: 'FAB001',
},
] as any);
const result = await context.service.products('12345');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('12345');
expect(result[0].name).toBe('PRODUTO EXEMPLO');
expect(context.mockRepository.findProducts).toHaveBeenCalledWith(
'12345',
);
});
it('should search products by CODAUXILIAR', async () => {
context.mockRepository.findProducts.mockResolvedValue([
{
id: '12345',
name: 'PRODUTO EXEMPLO',
manufacturerCode: 'FAB001',
},
] as any);
const result = await context.service.products('7891234567890');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('12345');
expect(context.mockRepository.findProducts).toHaveBeenCalledWith(
'7891234567890',
);
});
it('should search products by CODPROD or CODAUXILIAR', async () => {
context.mockRepository.findProducts.mockResolvedValue([
{
id: '12345',
name: 'PRODUTO EXEMPLO',
manufacturerCode: 'FAB001',
},
{
id: '12346',
name: 'OUTRO PRODUTO',
manufacturerCode: 'FAB002',
},
] as any);
const result = await context.service.products('12345');
expect(result).toHaveLength(2);
expect(result[0].id).toBe('12345');
expect(result[1].id).toBe('12346');
});
it('should handle empty result from repository', async () => {
context.mockRepository.findProducts.mockResolvedValue([]);
const result = await context.service.products('99999');
expect(result).toHaveLength(0);
expect(Array.isArray(result)).toBe(true);
});
it('should validate that all products have required properties (id, name)', async () => {
context.mockRepository.findProducts.mockResolvedValue([
{ id: '12345', name: 'PRODUTO 1' },
{ id: '12346', name: 'PRODUTO 2' },
{ id: '12347', name: 'PRODUTO 3' },
] as any);
const result = await context.service.products('12345');
result.forEach((product) => {
expect(product.id).toBeDefined();
expect(product.name).toBeDefined();
});
});
it('should throw error when filter is invalid', async () => {
await expect(
context.service.products(null as any),
).rejects.toThrow(HttpException);
await expect(
context.service.products(undefined as any),
).rejects.toThrow(HttpException);
await expect(
context.service.products('' as any),
).rejects.toThrow(HttpException);
});
it('should log error when repository throws exception', async () => {
const repositoryError = new Error('Database connection failed');
context.mockRepository.findProducts.mockRejectedValue(repositoryError);
await expect(context.service.products('12345')).rejects.toThrow(
HttpException,
);
expect(context.mockLogger.error).toHaveBeenCalledWith(
'Erro ao buscar produtos',
repositoryError,
);
});
});
});

View File

@@ -1,21 +1,16 @@
import { ApiTags, ApiOperation, ApiParam, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import {
ApiTags,
} from '@nestjs/swagger';
import { Controller, Get, Param } from '@nestjs/common';
import { clientesService } from './clientes.service';
@ApiTags('clientes')
@Controller('api/v1/')
export class clientesController {
constructor(private readonly clientesService: clientesService) {}
@Get('clientes/:filter')
async customer(@Param('filter') filter: string) {
return this.clientesService.customers(filter);
}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { clientesService } from './clientes.service';
import { clientesController } from './clientes.controller';

View File

@@ -63,7 +63,9 @@ export class clientesService {
' ( '||REGEXP_REPLACE(PCCLIENT.CGCENT, '[^0-9]', '')||' )' as "name"
,PCCLIENT.ESTCOB as "estcob"
FROM PCCLIENT
WHERE PCCLIENT.CLIENTE LIKE '${filter.toUpperCase().replace('@', '%')}%'
WHERE PCCLIENT.CLIENTE LIKE '${filter
.toUpperCase()
.replace('@', '%')}%'
ORDER BY PCCLIENT.CLIENTE`;
customers = await queryRunner.manager.query(sql);
}
@@ -72,7 +74,7 @@ export class clientesService {
} finally {
await queryRunner.release();
}
}
},
);
}
@@ -103,7 +105,7 @@ export class clientesService {
} finally {
await queryRunner.release();
}
}
},
);
}
@@ -136,19 +138,13 @@ export class clientesService {
} finally {
await queryRunner.release();
}
}
},
);
}
/**
* Limpar cache de clientes (útil para invalidação)
* @param pattern - Padrão de chaves para limpar (opcional)
*/
async clearCustomersCache(pattern?: string) {
const cachePattern = pattern || 'clientes:*';
// Nota: Esta funcionalidade requer implementação específica do Redis
// Por enquanto, mantemos a interface para futuras implementações
console.log(`Cache de clientes seria limpo para o padrão: ${cachePattern}`);
}
}

View File

@@ -1,7 +1,22 @@
import { Controller, Get, Param, Query, UseGuards, UsePipes, ValidationPipe, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import {
Controller,
Get,
Param,
Query,
UseGuards,
UsePipes,
ValidationPipe,
ParseIntPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiParam,
ApiBearerAuth,
ApiResponse,
} from '@nestjs/swagger';
import { DataConsultService } from './data-consult.service';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { ProductDto } from './dto/product.dto';
import { StoreDto } from './dto/store.dto';
import { SellerDto } from './dto/seller.dto';
@@ -13,14 +28,17 @@ import { CarrierDto, FindCarriersDto } from './dto/carrier.dto';
@ApiTags('DataConsult')
@Controller('api/v1/data-consult')
export class DataConsultController {
constructor(private readonly dataConsultService: DataConsultService) {}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('stores')
@ApiOperation({ summary: 'Lista todas as lojas' })
@ApiResponse({ status: 200, description: 'Lista de lojas retornada com sucesso', type: [StoreDto] })
@ApiResponse({
status: 200,
description: 'Lista de lojas retornada com sucesso',
type: [StoreDto],
})
async stores(): Promise<StoreDto[]> {
return this.dataConsultService.stores();
}
@@ -29,7 +47,11 @@ export class DataConsultController {
@ApiBearerAuth()
@Get('sellers')
@ApiOperation({ summary: 'Lista todos os vendedores' })
@ApiResponse({ status: 200, description: 'Lista de vendedores retornada com sucesso', type: [SellerDto] })
@ApiResponse({
status: 200,
description: 'Lista de vendedores retornada com sucesso',
type: [SellerDto],
})
async sellers(): Promise<SellerDto[]> {
return this.dataConsultService.sellers();
}
@@ -38,7 +60,11 @@ export class DataConsultController {
@ApiBearerAuth()
@Get('billings')
@ApiOperation({ summary: 'Retorna informações de faturamento' })
@ApiResponse({ status: 200, description: 'Informações de faturamento retornadas com sucesso', type: [BillingDto] })
@ApiResponse({
status: 200,
description: 'Informações de faturamento retornadas com sucesso',
type: [BillingDto],
})
async billings(): Promise<BillingDto[]> {
return this.dataConsultService.billings();
}
@@ -48,69 +74,111 @@ export class DataConsultController {
@Get('customers/:filter')
@ApiOperation({ summary: 'Filtra clientes pelo parâmetro fornecido' })
@ApiParam({ name: 'filter', description: 'Filtro de busca para clientes' })
@ApiResponse({ status: 200, description: 'Lista de clientes filtrados retornada com sucesso', type: [CustomerDto] })
@ApiResponse({
status: 200,
description: 'Lista de clientes filtrados retornada com sucesso',
type: [CustomerDto],
})
async customer(@Param('filter') filter: string): Promise<CustomerDto[]> {
return this.dataConsultService.customers(filter);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('products/codauxiliar/:codauxiliar')
@ApiOperation({ summary: 'Busca produtos por código auxiliar (EAN)' })
@ApiParam({ name: 'codauxiliar', description: 'Código auxiliar (EAN) do produto' })
@ApiResponse({
status: 200,
description: 'Lista de produtos encontrados por código auxiliar',
type: [ProductDto],
})
async productsByCodauxiliar(@Param('codauxiliar') codauxiliar: string): Promise<ProductDto[]> {
return this.dataConsultService.productsByCodauxiliar(codauxiliar);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('products/:filter')
@ApiOperation({ summary: 'Busca produtos filtrados' })
@ApiParam({ name: 'filter', description: 'Filtro de busca' })
@ApiResponse({ status: 200, description: 'Lista de produtos filtrados retornada com sucesso', type: [ProductDto] })
@ApiResponse({
status: 200,
description: 'Lista de produtos filtrados retornada com sucesso',
type: [ProductDto],
})
async products(@Param('filter') filter: string): Promise<ProductDto[]> {
return this.dataConsultService.products(filter);
}
@Get('all')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'VIEW DE 500 PRODUTOS' })
@ApiResponse({ status: 200, description: 'Lista de 500 produtos retornada com sucesso', type: [ProductDto] })
@Get('all')
@ApiOperation({ summary: 'Lista 500 produtos' })
@ApiResponse({
status: 200,
description: 'Lista de 500 produtos retornada com sucesso',
type: [ProductDto],
})
async getAllProducts(): Promise<ProductDto[]> {
return this.dataConsultService.getAllProducts();
}
@Get('carriers/all')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('carriers/all')
@ApiOperation({ summary: 'Lista todas as transportadoras cadastradas' })
@ApiResponse({ status: 200, description: 'Lista de transportadoras retornada com sucesso', type: [CarrierDto] })
@ApiResponse({
status: 200,
description: 'Lista de transportadoras retornada com sucesso',
type: [CarrierDto],
})
@UsePipes(new ValidationPipe({ transform: true }))
async getAllCarriers(): Promise<CarrierDto[]> {
return this.dataConsultService.getAllCarriers();
}
@Get('carriers')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('carriers')
@ApiOperation({ summary: 'Busca transportadoras por período de data' })
@ApiResponse({ status: 200, description: 'Lista de transportadoras por período retornada com sucesso', type: [CarrierDto] })
@ApiResponse({
status: 200,
description: 'Lista de transportadoras por período retornada com sucesso',
type: [CarrierDto],
})
@UsePipes(new ValidationPipe({ transform: true }))
async getCarriersByDate(@Query() query: FindCarriersDto): Promise<CarrierDto[]> {
async getCarriersByDate(
@Query() query: FindCarriersDto,
): Promise<CarrierDto[]> {
return this.dataConsultService.getCarriersByDate(query);
}
@Get('carriers/order/:orderId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('carriers/order/:orderId')
@ApiOperation({ summary: 'Busca transportadoras de um pedido específico' })
@ApiParam({ name: 'orderId', example: 236001388 })
@ApiResponse({ status: 200, description: 'Lista de transportadoras do pedido retornada com sucesso', type: [CarrierDto] })
@ApiResponse({
status: 200,
description: 'Lista de transportadoras do pedido retornada com sucesso',
type: [CarrierDto],
})
@UsePipes(new ValidationPipe({ transform: true }))
async getOrderCarriers(@Param('orderId', ParseIntPipe) orderId: number): Promise<CarrierDto[]> {
async getOrderCarriers(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<CarrierDto[]> {
return this.dataConsultService.getOrderCarriers(orderId);
}
@Get('regions')
//@UseGuards(JwtAuthGuard)
//@ApiBearerAuth()
@ApiOperation({ summary: 'Lista todas as regiões cadastradas' })
@ApiResponse({ status: 200, description: 'Lista de regiões retornada com sucesso', type: [RegionDto] })
@ApiResponse({
status: 200,
description: 'Lista de regiões retornada com sucesso',
type: [RegionDto],
})
async getRegions(): Promise<RegionDto[]> {
return this.dataConsultService.getRegions();
}
}

View File

@@ -2,18 +2,13 @@ import { Module } from '@nestjs/common';
import { DataConsultService } from './data-consult.service';
import { DataConsultController } from './data-consult.controller';
import { DataConsultRepository } from './data-consult.repository';
import { LoggerModule } from 'src/Log/logger.module';
import { ConfigModule } from '@nestjs/config';
import { RedisModule } from 'src/core/configs/cache/redis.module';
import { clientes } from './clientes.module';
@Module({
imports: [LoggerModule, ConfigModule, RedisModule, clientes],
imports: [ConfigModule, RedisModule, clientes],
controllers: [DataConsultController],
providers: [
DataConsultService,
DataConsultRepository,
],
providers: [DataConsultService, DataConsultRepository],
})
export class DataConsultModule {}

View File

@@ -1,6 +1,5 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { createOracleConfig } from '../core/configs/typeorm.oracle.config';
import { StoreDto } from './dto/store.dto';
import { SellerDto } from './dto/seller.dto';
import { BillingDto } from './dto/billing.dto';
@@ -14,7 +13,7 @@ import { DATA_SOURCE } from '../core/constants';
export class DataConsultRepository {
constructor(
@Inject(DATA_SOURCE) private readonly dataSource: DataSource,
private readonly configService: ConfigService
private readonly configService: ConfigService,
) {}
private async executeQuery<T>(sql: string, params: any[] = []): Promise<T> {
@@ -38,7 +37,7 @@ export class DataConsultRepository {
ORDER BY TO_NUMBER(PCFILIAL.CODIGO)
`;
const results = await this.executeQuery<StoreDto[]>(sql);
return results.map(result => new StoreDto(result));
return results.map((result) => new StoreDto(result));
}
async findSellers(): Promise<SellerDto[]> {
@@ -51,27 +50,28 @@ async findSellers(): Promise<SellerDto[]> {
AND (PCUSUARI.BLOQUEIO IS NULL OR PCUSUARI.BLOQUEIO = 'N')
`;
const results = await this.executeQuery<SellerDto[]>(sql);
return results.map(result => new SellerDto(result));
return results.map((result) => new SellerDto(result));
}
async findBillings(): Promise<BillingDto[]> {
const sql = `
SELECT p.CODCOB, p.COBRANCA FROM PCCOB p
SELECT p.CODCOB as "id",
SYSDATE as "date",
0 as "total",
p.COBRANCA as "description"
FROM PCCOB p
`;
const results = await this.executeQuery<BillingDto[]>(sql);
return results.map(result => new BillingDto(result));
return results.map((result) => new BillingDto(result));
}
async findCustomers(filter: string): Promise<CustomerDto[]> {
// 1) limpa todos os não-dígitos para buscas exatas
const cleanedDigits = filter.replace(/\D/g, '');
// 2) prepara filtro para busca por nome (LIKE)
const likeFilter = `%${filter.toUpperCase().replace(/@/g, '%')}%`;
let customers: CustomerDto[] = [];
// --- 1ª tentativa: busca por código do cliente (CODCLI) ---
let sql = `
SELECT
PCCLIENT.CODCLI AS "id",
@@ -84,7 +84,6 @@ async findSellers(): Promise<SellerDto[]> {
`;
customers = await this.executeQuery<CustomerDto[]>(sql, [cleanedDigits]);
// --- 2ª tentativa: busca por CPF/CNPJ (CGCENT) ---
if (customers.length === 0) {
sql = `
SELECT
@@ -99,7 +98,6 @@ async findSellers(): Promise<SellerDto[]> {
customers = await this.executeQuery<CustomerDto[]>(sql, [cleanedDigits]);
}
// --- 3ª tentativa: busca parcial por nome ---
if (customers.length === 0) {
sql = `
SELECT
@@ -114,18 +112,31 @@ async findSellers(): Promise<SellerDto[]> {
customers = await this.executeQuery<CustomerDto[]>(sql, [likeFilter]);
}
return customers.map(row => new CustomerDto(row));
return customers.map((row) => new CustomerDto(row));
}
async findProducts(filter: string): Promise<ProductDto[]> {
const cleanedFilter = filter.replace(/\D/g, '');
const sql = `
SELECT PCPRODUT.CODPROD as "id",
PCPRODUT.CODPROD || ' - ' || PCPRODUT.DESCRICAO || ' ( ' || PCPRODUT.CODFAB || ' )' as "description"
FROM PCPRODUT
WHERE PCPRODUT.CODPROD = :filter
WHERE PCPRODUT.CODPROD = :0
OR REGEXP_REPLACE(PCPRODUT.CODAUXILIAR, '[^0-9]', '') = :1
`;
const results = await this.executeQuery<ProductDto[]>(sql, [filter]);
return results.map(result => new ProductDto(result));
const results = await this.executeQuery<ProductDto[]>(sql, [filter, cleanedFilter]);
return results.map((result) => new ProductDto(result));
}
async findProductsByCodauxiliar(codauxiliar: string): Promise<ProductDto[]> {
const sql = `
SELECT PCPRODUT.CODPROD as "id",
PCPRODUT.CODPROD || ' - ' || PCPRODUT.DESCRICAO || ' ( ' || PCPRODUT.CODFAB || ' )' as "description"
FROM PCPRODUT
WHERE REGEXP_REPLACE(PCPRODUT.CODAUXILIAR, '[^0-9]', '') = REGEXP_REPLACE(:codauxiliar, '[^0-9]', '')
`;
const results = await this.dataSource.query(sql, [codauxiliar]);
return results.map((result) => new ProductDto(result));
}
async findAllProducts(): Promise<ProductDto[]> {
@@ -136,12 +147,9 @@ async findSellers(): Promise<SellerDto[]> {
WHERE ROWNUM <= 500
`;
const results = await this.executeQuery<ProductDto[]>(sql);
return results.map(result => new ProductDto(result));
return results.map((result) => new ProductDto(result));
}
/**
* Busca todas as transportadoras cadastradas no sistema
*/
async findAllCarriers(): Promise<any[]> {
const sql = `
SELECT DISTINCT
@@ -157,9 +165,6 @@ async findSellers(): Promise<SellerDto[]> {
return await this.executeQuery<any[]>(sql);
}
/**
* Busca as transportadoras por período de data
*/
async findCarriersByDate(query: any): Promise<any[]> {
let sql = `
SELECT DISTINCT
@@ -178,12 +183,16 @@ async findSellers(): Promise<SellerDto[]> {
let paramIndex = 0;
if (query.dateIni) {
conditions.push(`AND PCPEDC.DATA >= TO_DATE(:${paramIndex}, 'YYYY-MM-DD')`);
conditions.push(
`AND PCPEDC.DATA >= TO_DATE(:${paramIndex}, 'YYYY-MM-DD')`,
);
parameters.push(query.dateIni);
paramIndex++;
}
if (query.dateEnd) {
conditions.push(`AND PCPEDC.DATA <= TO_DATE(:${paramIndex}, 'YYYY-MM-DD')`);
conditions.push(
`AND PCPEDC.DATA <= TO_DATE(:${paramIndex}, 'YYYY-MM-DD')`,
);
parameters.push(query.dateEnd);
paramIndex++;
}
@@ -193,16 +202,13 @@ async findSellers(): Promise<SellerDto[]> {
paramIndex++;
}
sql += "\n" + conditions.join("\n");
sql += "\nGROUP BY PCPEDC.CODFORNECFRETE, PCFORNEC.FORNECEDOR";
sql += "\nORDER BY PCPEDC.CODFORNECFRETE";
sql += '\n' + conditions.join('\n');
sql += '\nGROUP BY PCPEDC.CODFORNECFRETE, PCFORNEC.FORNECEDOR';
sql += '\nORDER BY PCPEDC.CODFORNECFRETE';
return await this.executeQuery<any[]>(sql, parameters);
}
/**
* Busca as transportadoras de um pedido específico
*/
async findOrderCarriers(orderId: number): Promise<any[]> {
const sql = `
SELECT DISTINCT
@@ -219,9 +225,6 @@ async findSellers(): Promise<SellerDto[]> {
return await this.executeQuery<any[]>(sql, [orderId]);
}
/**
* Busca todas as regiões cadastradas
*/
async findRegions(): Promise<RegionDto[]> {
const sql = `
SELECT
@@ -231,6 +234,6 @@ async findSellers(): Promise<SellerDto[]> {
ORDER BY PCREGIAO.NUMREGIAO
`;
const results = await this.executeQuery<RegionDto[]>(sql);
return results.map(result => new RegionDto(result));
return results.map((result) => new RegionDto(result));
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, HttpException, HttpStatus, Inject } from '@nestjs/common';
import { Injectable, HttpException, HttpStatus, Inject, Logger } from '@nestjs/common';
import { DataConsultRepository } from './data-consult.repository';
import { StoreDto } from './dto/store.dto';
import { SellerDto } from './dto/seller.dto';
@@ -7,7 +7,6 @@ import { CustomerDto } from './dto/customer.dto';
import { ProductDto } from './dto/product.dto';
import { RegionDto } from './dto/region.dto';
import { CarrierDto, FindCarriersDto } from './dto/carrier.dto';
import { ILogger } from '../Log/ILogger';
import { RedisClientToken } from '../core/configs/cache/redis-client.adapter.provider';
import { IRedisClient } from '../core/configs/cache/IRedisClient';
import { getOrSetCache } from '../shared/cache.util';
@@ -16,6 +15,7 @@ import { DATA_SOURCE } from '../core/constants';
@Injectable()
export class DataConsultService {
private readonly logger = new Logger(DataConsultService.name);
private readonly SELLERS_CACHE_KEY = 'data-consult:sellers';
private readonly SELLERS_TTL = 3600;
private readonly STORES_TTL = 3600;
@@ -31,8 +31,7 @@ export class DataConsultService {
constructor(
private readonly repository: DataConsultRepository,
@Inject(RedisClientToken) private readonly redisClient: IRedisClient,
@Inject('LoggerService') private readonly logger: ILogger,
@Inject(DATA_SOURCE) private readonly dataSource: DataSource
@Inject(DATA_SOURCE) private readonly dataSource: DataSource,
) {}
async stores(): Promise<StoreDto[]> {
@@ -41,25 +40,38 @@ export class DataConsultService {
const stores = await this.repository.findStores();
if (stores === null || stores === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const storesArray = Array.isArray(stores) ? stores : [stores];
return storesArray
.filter(store => {
.filter((store) => {
if (!store || typeof store !== 'object') {
return false;
}
const hasId = store.id !== undefined && store.id !== null && store.id !== '';
const hasName = store.name !== undefined && store.name !== null && store.name !== '';
const hasStore = store.store !== undefined && store.store !== null && store.store !== '';
const hasId =
store.id !== undefined && store.id !== null && store.id !== '';
const hasName =
store.name !== undefined &&
store.name !== null &&
store.name !== '';
const hasStore =
store.store !== undefined &&
store.store !== null &&
store.store !== '';
return hasId && hasName && hasStore;
})
.map(store => new StoreDto(store));
.map((store) => new StoreDto(store));
} catch (error) {
this.logger.error('Erro ao buscar lojas', error);
throw new HttpException('Erro ao buscar lojas', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar lojas',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@@ -75,30 +87,42 @@ export class DataConsultService {
const sellers = await this.repository.findSellers();
if (sellers === null || sellers === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const sellersArray = Array.isArray(sellers) ? sellers : [sellers];
return sellersArray
.filter(seller => {
.filter((seller) => {
if (!seller || typeof seller !== 'object') {
return false;
}
const hasId = seller.id !== undefined && seller.id !== null && seller.id !== '';
const hasName = seller.name !== undefined && seller.name !== null && seller.name !== '';
const hasId =
seller.id !== undefined &&
seller.id !== null &&
seller.id !== '';
const hasName =
seller.name !== undefined &&
seller.name !== null &&
seller.name !== '';
return hasId && hasName;
})
.map(seller => new SellerDto(seller));
.map((seller) => new SellerDto(seller));
} catch (error) {
this.logger.error('Erro ao buscar vendedores', error);
throw error;
}
}
},
);
} catch (error) {
this.logger.error('Erro ao buscar vendedores', error);
throw new HttpException('Erro ao buscar vendedores', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar vendedores',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@@ -108,25 +132,35 @@ export class DataConsultService {
const billings = await this.repository.findBillings();
if (billings === null || billings === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const billingsArray = Array.isArray(billings) ? billings : [billings];
return billingsArray
.filter(billing => {
.filter((billing) => {
if (!billing || typeof billing !== 'object') {
return false;
}
const hasId = billing.id !== undefined && billing.id !== null && billing.id !== '';
const hasId =
billing.id !== undefined &&
billing.id !== null &&
billing.id !== '';
const hasDate = billing.date !== undefined && billing.date !== null;
const hasTotal = billing.total !== undefined && billing.total !== null;
const hasTotal =
billing.total !== undefined && billing.total !== null;
return hasId && hasDate && hasTotal;
})
.map(billing => new BillingDto(billing));
.map((billing) => new BillingDto(billing));
} catch (error) {
this.logger.error('Erro ao buscar faturamento', error);
throw new HttpException('Erro ao buscar faturamento', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar faturamento',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@@ -139,25 +173,40 @@ export class DataConsultService {
const customers = await this.repository.findCustomers(filter);
if (customers === null || customers === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const customersArray = Array.isArray(customers) ? customers : [customers];
return customersArray
.filter(customer => {
.filter((customer) => {
if (!customer || typeof customer !== 'object') {
return false;
}
const hasId = customer.id !== undefined && customer.id !== null && customer.id !== '';
const hasName = customer.name !== undefined && customer.name !== null && customer.name !== '';
const hasDocument = customer.document !== undefined && customer.document !== null && customer.document !== '';
const hasId =
customer.id !== undefined &&
customer.id !== null &&
customer.id !== '';
const hasName =
customer.name !== undefined &&
customer.name !== null &&
customer.name !== '';
const hasDocument =
customer.document !== undefined &&
customer.document !== null &&
customer.document !== '';
return hasId && hasName && hasDocument;
})
.map(customer => new CustomerDto(customer));
.map((customer) => new CustomerDto(customer));
} catch (error) {
this.logger.error('Erro ao buscar clientes', error);
throw new HttpException('Erro ao buscar clientes', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar clientes',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@@ -168,10 +217,30 @@ export class DataConsultService {
throw new HttpException('Filtro inválido', HttpStatus.BAD_REQUEST);
}
const products = await this.repository.findProducts(filter);
return products.map(product => new ProductDto(product));
return products.map((product) => new ProductDto(product));
} catch (error) {
this.logger.error('Erro ao buscar produtos', error);
throw new HttpException('Erro ao buscar produtos', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar produtos',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async productsByCodauxiliar(codauxiliar: string): Promise<ProductDto[]> {
this.logger.log(`Buscando produtos por codauxiliar: ${codauxiliar}`);
try {
if (!codauxiliar || typeof codauxiliar !== 'string') {
throw new HttpException('Código auxiliar inválido', HttpStatus.BAD_REQUEST);
}
const products = await this.repository.findProductsByCodauxiliar(codauxiliar);
return products.map((product) => new ProductDto(product));
} catch (error) {
this.logger.error('Erro ao buscar produtos por codauxiliar', error);
throw new HttpException(
'Erro ao buscar produtos por codauxiliar',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@@ -187,31 +256,48 @@ export class DataConsultService {
const products = await this.repository.findAllProducts();
if (products === null || products === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const productsArray = Array.isArray(products) ? products : [products];
const productsArray = Array.isArray(products)
? products
: [products];
return productsArray
.filter(product => {
.filter((product) => {
if (!product || typeof product !== 'object') {
return false;
}
const hasId = product.id !== undefined && product.id !== null && product.id !== '';
const hasName = product.name !== undefined && product.name !== null && product.name !== '';
const hasManufacturerCode = product.manufacturerCode !== undefined && product.manufacturerCode !== null && product.manufacturerCode !== '';
const hasId =
product.id !== undefined &&
product.id !== null &&
product.id !== '';
const hasName =
product.name !== undefined &&
product.name !== null &&
product.name !== '';
const hasManufacturerCode =
product.manufacturerCode !== undefined &&
product.manufacturerCode !== null &&
product.manufacturerCode !== '';
return hasId && hasName && hasManufacturerCode;
})
.map(product => new ProductDto(product));
.map((product) => new ProductDto(product));
} catch (error) {
this.logger.error('Erro ao buscar todos os produtos', error);
throw error;
}
}
},
);
} catch (error) {
this.logger.error('Erro ao buscar todos os produtos', error);
throw new HttpException('Erro ao buscar produtos', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar produtos',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@@ -227,22 +313,36 @@ export class DataConsultService {
const carriers = await this.repository.findAllCarriers();
if (carriers === null || carriers === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const carriersArray = Array.isArray(carriers) ? carriers : [carriers];
const carriersArray = Array.isArray(carriers)
? carriers
: [carriers];
return carriersArray
.filter(carrier => {
.filter((carrier) => {
if (!carrier || typeof carrier !== 'object') {
return false;
}
const hasCarrierId = carrier.carrierId !== undefined && carrier.carrierId !== null && carrier.carrierId !== '';
const hasCarrierName = carrier.carrierName !== undefined && carrier.carrierName !== null && carrier.carrierName !== '';
const hasCarrierDescription = carrier.carrierDescription !== undefined && carrier.carrierDescription !== null && carrier.carrierDescription !== '';
const hasCarrierId =
carrier.carrierId !== undefined &&
carrier.carrierId !== null &&
carrier.carrierId !== '';
const hasCarrierName =
carrier.carrierName !== undefined &&
carrier.carrierName !== null &&
carrier.carrierName !== '';
const hasCarrierDescription =
carrier.carrierDescription !== undefined &&
carrier.carrierDescription !== null &&
carrier.carrierDescription !== '';
return hasCarrierId && hasCarrierName && hasCarrierDescription;
})
.map(carrier => ({
.map((carrier) => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
@@ -251,19 +351,24 @@ export class DataConsultService {
this.logger.error('Erro ao buscar transportadoras', error);
throw error;
}
}
},
);
} catch (error) {
this.logger.error('Erro ao buscar transportadoras', error);
throw new HttpException('Erro ao buscar transportadoras', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar transportadoras',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getCarriersByDate(query: FindCarriersDto): Promise<CarrierDto[]> {
this.logger.log(`Buscando transportadoras por período: ${JSON.stringify(query)}`);
this.logger.log(
`Buscando transportadoras por período: ${JSON.stringify(query)}`,
);
try {
const carriers = await this.repository.findCarriersByDate(query);
return carriers.map(carrier => ({
return carriers.map((carrier) => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
@@ -271,7 +376,10 @@ export class DataConsultService {
}));
} catch (error) {
this.logger.error('Erro ao buscar transportadoras por período', error);
throw new HttpException('Erro ao buscar transportadoras', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar transportadoras',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@@ -279,14 +387,17 @@ export class DataConsultService {
this.logger.log(`Buscando transportadoras do pedido: ${orderId}`);
try {
const carriers = await this.repository.findOrderCarriers(orderId);
return carriers.map(carrier => ({
return carriers.map((carrier) => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
}));
} catch (error) {
this.logger.error('Erro ao buscar transportadoras do pedido', error);
throw new HttpException('Erro ao buscar transportadoras do pedido', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar transportadoras do pedido',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@@ -302,30 +413,40 @@ export class DataConsultService {
const regions = await this.repository.findRegions();
if (regions === null || regions === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const regionsArray = Array.isArray(regions) ? regions : [regions];
return regionsArray
.filter(region => {
.filter((region) => {
if (!region || typeof region !== 'object') {
return false;
}
const hasNumregiao = region.numregiao !== undefined && region.numregiao !== null;
const hasRegiao = region.regiao !== undefined && region.regiao !== null && region.regiao !== '';
const hasNumregiao =
region.numregiao !== undefined && region.numregiao !== null;
const hasRegiao =
region.regiao !== undefined &&
region.regiao !== null &&
region.regiao !== '';
return hasNumregiao && hasRegiao;
})
.map(region => new RegionDto(region));
.map((region) => new RegionDto(region));
} catch (error) {
this.logger.error('Erro ao buscar regiões', error);
throw error;
}
}
},
);
} catch (error) {
this.logger.error('Erro ao buscar regiões', error);
throw new HttpException('Erro ao buscar regiões', HttpStatus.INTERNAL_SERVER_ERROR);
throw new HttpException(
'Erro ao buscar regiões',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@@ -4,26 +4,26 @@ import { IsOptional, IsString, IsDateString } from 'class-validator';
export class CarrierDto {
@ApiProperty({
description: 'ID da transportadora',
example: '123'
example: '123',
})
carrierId: string;
@ApiProperty({
description: 'Nome da transportadora',
example: 'TRANSPORTADORA ABC LTDA'
example: 'TRANSPORTADORA ABC LTDA',
})
carrierName: string;
@ApiProperty({
description: 'Descrição completa da transportadora (ID - Nome)',
example: '123 - TRANSPORTADORA ABC LTDA'
example: '123 - TRANSPORTADORA ABC LTDA',
})
carrierDescription: string;
@ApiProperty({
description: 'Quantidade de pedidos da transportadora no período',
example: 15,
required: false
required: false,
})
ordersCount?: number;
}
@@ -32,7 +32,7 @@ export class FindCarriersDto {
@ApiProperty({
description: 'Data inicial para filtro (formato YYYY-MM-DD)',
example: '2024-01-01',
required: false
required: false,
})
@IsOptional()
@IsDateString()
@@ -41,7 +41,7 @@ export class FindCarriersDto {
@ApiProperty({
description: 'Data final para filtro (formato YYYY-MM-DD)',
example: '2024-12-31',
required: false
required: false,
})
@IsOptional()
@IsDateString()
@@ -50,7 +50,7 @@ export class FindCarriersDto {
@ApiProperty({
description: 'ID da filial',
example: '1',
required: false
required: false,
})
@IsOptional()
@IsString()

View File

@@ -20,4 +20,3 @@ export class RegionDto {
Object.assign(this, partial);
}
}

View File

@@ -1,258 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import { HealthCheckResult } from '@nestjs/terminus';
@Injectable()
export class HealthAlertService {
private readonly logger = new Logger(HealthAlertService.name);
private readonly webhookUrls: Record<string, string>;
private readonly alertThresholds: Record<string, any>;
private readonly alertCooldowns: Map<string, number> = new Map();
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
// Configurações de webhooks para diferentes canais de alerta
this.webhookUrls = {
slack: this.configService.get<string>('ALERT_WEBHOOK_SLACK'),
teams: this.configService.get<string>('ALERT_WEBHOOK_TEAMS'),
email: this.configService.get<string>('ALERT_WEBHOOK_EMAIL'),
};
// Thresholds para diferentes tipos de alerta
this.alertThresholds = {
disk: {
criticalPercent: this.configService.get<number>('ALERT_DISK_CRITICAL_PERCENT', 90),
warningPercent: this.configService.get<number>('ALERT_DISK_WARNING_PERCENT', 80),
},
memory: {
criticalPercent: this.configService.get<number>('ALERT_MEMORY_CRITICAL_PERCENT', 90),
warningPercent: this.configService.get<number>('ALERT_MEMORY_WARNING_PERCENT', 80),
},
db: {
cooldownMinutes: this.configService.get<number>('ALERT_DB_COOLDOWN_MINUTES', 15),
},
};
}
async processHealthCheckResult(result: HealthCheckResult): Promise<void> {
try {
const { status, info, error, details } = result;
// Se o status geral não for 'ok', envie um alerta
if (status !== 'ok') {
// Verificar quais componentes estão com problema
const failedComponents = Object.entries(error)
.map(([key, value]) => ({ key, value }));
if (failedComponents.length > 0) {
await this.sendAlert('critical', 'Health Check Falhou',
`Os seguintes componentes estão com problemas: ${failedComponents.map(c => c.key).join(', ')}`,
{ result, failedComponents }
);
}
}
// Verificar alertas específicos para cada tipo de componente
if (details.disk) {
await this.checkDiskAlerts(details.disk);
}
if (details.memory_heap) {
await this.checkMemoryAlerts(details.memory_heap);
}
// Verificar alertas de banco de dados
['oracle', 'postgres'].forEach(db => {
if (details[db] && details[db].status !== 'up') {
this.checkDatabaseAlerts(db, details[db]);
}
});
} catch (error) {
this.logger.error(`Erro ao processar health check result: ${error.message}`, error.stack);
}
}
private async checkDiskAlerts(diskDetails: any): Promise<void> {
try {
if (!diskDetails.freeBytes || !diskDetails.totalBytes) {
return;
}
const usedPercent = ((diskDetails.totalBytes - diskDetails.freeBytes) / diskDetails.totalBytes) * 100;
if (usedPercent >= this.alertThresholds.disk.criticalPercent) {
await this.sendAlert('critical', 'Espaço em Disco Crítico',
`O uso de disco está em ${usedPercent.toFixed(1)}%, acima do limite crítico de ${this.alertThresholds.disk.criticalPercent}%`,
{ diskDetails, usedPercent }
);
} else if (usedPercent >= this.alertThresholds.disk.warningPercent) {
await this.sendAlert('warning', 'Alerta de Espaço em Disco',
`O uso de disco está em ${usedPercent.toFixed(1)}%, acima do limite de alerta de ${this.alertThresholds.disk.warningPercent}%`,
{ diskDetails, usedPercent }
);
}
} catch (error) {
this.logger.error(`Erro ao verificar alertas de disco: ${error.message}`);
}
}
private async checkMemoryAlerts(memoryDetails: any): Promise<void> {
try {
if (!memoryDetails.usedBytes || !memoryDetails.thresholdBytes) {
return;
}
const usedPercent = (memoryDetails.usedBytes / memoryDetails.thresholdBytes) * 100;
if (usedPercent >= this.alertThresholds.memory.criticalPercent) {
await this.sendAlert('critical', 'Uso de Memória Crítico',
`O uso de memória heap está em ${usedPercent.toFixed(1)}%, acima do limite crítico de ${this.alertThresholds.memory.criticalPercent}%`,
{ memoryDetails, usedPercent }
);
} else if (usedPercent >= this.alertThresholds.memory.warningPercent) {
await this.sendAlert('warning', 'Alerta de Uso de Memória',
`O uso de memória heap está em ${usedPercent.toFixed(1)}%, acima do limite de alerta de ${this.alertThresholds.memory.warningPercent}%`,
{ memoryDetails, usedPercent }
);
}
} catch (error) {
this.logger.error(`Erro ao verificar alertas de memória: ${error.message}`);
}
}
private async checkDatabaseAlerts(dbName: string, dbDetails: any): Promise<void> {
try {
const now = Date.now();
const lastAlertTime = this.alertCooldowns.get(dbName) || 0;
const cooldownMs = this.alertThresholds.db.cooldownMinutes * 60 * 1000;
// Verifica se já passou o período de cooldown para este banco
if (now - lastAlertTime >= cooldownMs) {
await this.sendAlert('critical', `Problema de Conexão com Banco de Dados ${dbName}`,
`A conexão com o banco de dados ${dbName} está com problemas: ${dbDetails.message || 'Erro não especificado'}`,
{ dbName, dbDetails }
);
// Atualiza o timestamp do último alerta
this.alertCooldowns.set(dbName, now);
}
} catch (error) {
this.logger.error(`Erro ao verificar alertas de banco de dados: ${error.message}`);
}
}
private async sendAlert(
severity: 'critical' | 'warning' | 'info',
title: string,
message: string,
details?: any,
): Promise<void> {
try {
const environment = this.configService.get<string>('NODE_ENV', 'development');
const appName = this.configService.get<string>('APP_NAME', 'Portal Jurunense API');
this.logger.warn(`[${severity.toUpperCase()}] ${title}: ${message}`);
const payload = {
severity,
title: `[${environment.toUpperCase()}] [${appName}] ${title}`,
message,
timestamp: new Date().toISOString(),
details: details || {},
environment,
};
// Enviar para Slack, se configurado
if (this.webhookUrls.slack) {
await this.sendSlackAlert(payload);
}
// Enviar para Microsoft Teams, se configurado
if (this.webhookUrls.teams) {
await this.sendTeamsAlert(payload);
}
// Enviar para serviço de email, se configurado
if (this.webhookUrls.email) {
await this.sendEmailAlert(payload);
}
} catch (error) {
this.logger.error(`Erro ao enviar alerta: ${error.message}`, error.stack);
}
}
private async sendSlackAlert(payload: any): Promise<void> {
try {
const slackPayload = {
text: `${payload.title}`,
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: payload.title,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Mensagem:* ${payload.message}\n*Severidade:* ${payload.severity}\n*Ambiente:* ${payload.environment}\n*Timestamp:* ${payload.timestamp}`,
},
},
],
};
await firstValueFrom(this.httpService.post(this.webhookUrls.slack, slackPayload));
} catch (error) {
this.logger.error(`Erro ao enviar alerta para Slack: ${error.message}`);
}
}
private async sendTeamsAlert(payload: any): Promise<void> {
try {
const teamsPayload = {
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": payload.severity === 'critical' ? "FF0000" : (payload.severity === 'warning' ? "FFA500" : "0078D7"),
"summary": payload.title,
"sections": [
{
"activityTitle": payload.title,
"activitySubtitle": `Severidade: ${payload.severity} | Ambiente: ${payload.environment}`,
"text": payload.message,
"facts": [
{
"name": "Timestamp",
"value": payload.timestamp
}
]
}
]
};
await firstValueFrom(this.httpService.post(this.webhookUrls.teams, teamsPayload));
} catch (error) {
this.logger.error(`Erro ao enviar alerta para Microsoft Teams: ${error.message}`);
}
}
private async sendEmailAlert(payload: any): Promise<void> {
try {
const emailPayload = {
subject: payload.title,
text: `${payload.message}\n\nSeveridade: ${payload.severity}\nAmbiente: ${payload.environment}\nTimestamp: ${payload.timestamp}`,
html: `<h2>${payload.title}</h2><p>${payload.message}</p><p><strong>Severidade:</strong> ${payload.severity}<br><strong>Ambiente:</strong> ${payload.environment}<br><strong>Timestamp:</strong> ${payload.timestamp}</p>`,
};
await firstValueFrom(this.httpService.post(this.webhookUrls.email, emailPayload));
} catch (error) {
this.logger.error(`Erro ao enviar alerta por email: ${error.message}`);
}
}
}

View File

@@ -1,119 +0,0 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import {
HealthCheck,
HealthCheckService,
HttpHealthIndicator,
DiskHealthIndicator,
MemoryHealthIndicator,
} from '@nestjs/terminus';
import { TypeOrmHealthIndicator } from './indicators/typeorm.health';
import { DbPoolStatsIndicator } from './indicators/db-pool-stats.health';
import { ConfigService } from '@nestjs/config';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import * as os from 'os';
@ApiTags('Health Check')
@Controller('health')
export class HealthController {
private readonly diskPath: string;
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
private disk: DiskHealthIndicator,
private memory: MemoryHealthIndicator,
private typeOrmHealth: TypeOrmHealthIndicator,
private dbPoolStats: DbPoolStatsIndicator,
private configService: ConfigService,
) {
this.diskPath = os.platform() === 'win32' ? 'C:\\' : '/';
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get()
@HealthCheck()
@ApiOperation({ summary: 'Verificar saúde geral da aplicação' })
check() {
return this.health.check([
// Verifica o status da própria aplicação
() => this.http.pingCheck('api', 'http://localhost:8066/docs'),
// Verifica espaço em disco (espaço livre < 80%)
() => this.disk.checkStorage('disk_percent', {
path: this.diskPath,
thresholdPercent: 0.8, // 80%
}),
// Verifica espaço em disco (pelo menos 500MB livres)
() => this.disk.checkStorage('disk_space', {
path: this.diskPath,
threshold: 500 * 1024 * 1024, // 500MB em bytes
}),
// Verifica uso de memória (heap <150MB)
() => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024), // 150MB
// Verifica as conexões de banco de dados
() => this.typeOrmHealth.checkOracle(),
() => this.typeOrmHealth.checkPostgres(),
]);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('db')
@HealthCheck()
@ApiOperation({ summary: 'Verificar saúde das conexões de banco de dados' })
checkDatabase() {
return this.health.check([
() => this.typeOrmHealth.checkOracle(),
() => this.typeOrmHealth.checkPostgres(),
]);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('memory')
@HealthCheck()
@ApiOperation({ summary: 'Verificar uso de memória' })
checkMemory() {
return this.health.check([
() => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024),
() => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024),
]);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('disk')
@HealthCheck()
@ApiOperation({ summary: 'Verificar espaço em disco' })
checkDisk() {
return this.health.check([
// Verificar espaço em disco usando porcentagem
() => this.disk.checkStorage('disk_percent', {
path: this.diskPath,
thresholdPercent: 0.8,
}),
// Verificar espaço em disco usando valor absoluto
() => this.disk.checkStorage('disk_space', {
path: this.diskPath,
threshold: 500 * 1024 * 1024,
}),
]);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('pool')
@HealthCheck()
@ApiOperation({ summary: 'Verificar estatísticas do pool de conexões' })
checkPoolStats() {
return this.health.check([
() => this.dbPoolStats.checkOraclePoolStats(),
() => this.dbPoolStats.checkPostgresPoolStats(),
]);
}
}

View File

@@ -1,44 +0,0 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { HealthController } from './health.controller';
import { TypeOrmHealthIndicator } from './indicators/typeorm.health';
import { DbPoolStatsIndicator } from './indicators/db-pool-stats.health';
import { ConfigModule } from '@nestjs/config';
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
import { metricProviders } from './metrics/metrics.config';
import { CustomMetricsService } from './metrics/custom.metrics';
import { MetricsInterceptor } from './metrics/metrics.interceptor';
import { HealthAlertService } from './alert/health-alert.service';
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
imports: [
TerminusModule,
HttpModule,
ConfigModule,
PrometheusModule.register({
path: '/metrics',
defaultMetrics: {
enabled: true,
},
}),
],
controllers: [HealthController],
providers: [
TypeOrmHealthIndicator,
DbPoolStatsIndicator,
CustomMetricsService,
HealthAlertService,
{
provide: APP_INTERCEPTOR,
useClass: MetricsInterceptor,
},
...metricProviders,
],
exports: [
CustomMetricsService,
HealthAlertService,
],
})
export class HealthModule {}

View File

@@ -1,193 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import {
HealthIndicator,
HealthIndicatorResult,
HealthCheckError, // Import HealthCheckError for better terminus integration
} from '@nestjs/terminus';
import { InjectConnection } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
const ORACLE_HEALTH_KEY = 'oracle_pool_stats';
const POSTGRES_HEALTH_KEY = 'postgres_pool_stats';
const ORACLE_PROGRAM_PATTERN = 'node%'; // Default pattern for Oracle
const POSTGRES_APP_NAME_PATTERN = 'nodejs%'; // Default pattern for PostgreSQL
@Injectable()
export class DbPoolStatsIndicator extends HealthIndicator {
private readonly logger = new Logger(DbPoolStatsIndicator.name);
constructor(
@InjectConnection('oracle') private readonly oracleDataSource: DataSource,
@InjectConnection('postgres') private readonly postgresDataSource: DataSource,
) {
super();
}
/**
* Verifica a integridade do pool de conexões Oracle consultando V$SESSION.
* Observações: Requer privilégios SELECT em V$SESSION e depende da coluna PROGRAM.
* Isso verifica principalmente a acessibilidade do banco de dados e o sucesso da execução da consulta.
* Considere estatísticas de pool em nível de driver para obter uma integridade de pool mais precisa, se disponível.
*
* @param key Custom key for the health indicator component.
* @param programLike Optional pattern to match the PROGRAM column in V$SESSION.
*/
async checkOraclePoolStats(
key: string = ORACLE_HEALTH_KEY,
programLike: string = ORACLE_PROGRAM_PATTERN,
): Promise<HealthIndicatorResult> {
try {
// Usar parâmetros de consulta é uma boa prática, embora menos crítica para LIKE com um padrão fixo.
// Oracle usa a sintaxe :paramName
const query = `
SELECT
COUNT(*) AS "totalConnections" -- Use quoted identifiers if needed, or match case below
FROM
V$SESSION
WHERE
TYPE = 'USER'
AND PROGRAM LIKE :pattern
`;
const params = { pattern: programLike };
const results: { totalConnections: number | string }[] =
await this.oracleDataSource.query(query, [params.pattern]); // Pass parameters as an array for Oracle usually
if (!results || results.length === 0) {
this.logger.warn(`Oracle V$SESSION query returned no results for pattern '${programLike}'`);
}
const totalConnections = parseInt(String(results?.[0]?.totalConnections ?? 0), 10);
if (isNaN(totalConnections)) {
throw new Error('Failed to parse totalConnections from Oracle V$SESSION query result.');
}
// isHealthy é verdadeiro se a consulta for executada sem gerar um erro.
// Adicione lógica aqui se contagens de conexão específicas indicarem estado não íntegro (por exemplo, > poolMax)
const isHealthy = true;
const details = {
totalConnections: totalConnections,
programPattern: programLike,
};
return this.getStatus(key, isHealthy, details);
} catch (error) {
this.logger.error(`Oracle pool stats check failed for key "${key}": ${error.message}`, error.stack);
throw new HealthCheckError(
`${key} check failed`,
this.getStatus(key, false, { message: error.message }),
);
}
}
/**
* Verifica a integridade do pool de conexões do PostgreSQL consultando pg_stat_activity.
* Observações: Depende de o application_name estar definido corretamente na string de conexão ou nas opções.
* Isso verifica principalmente a acessibilidade do banco de dados e o sucesso da execução da consulta.
* Considere estatísticas de pool em nível de driver para obter uma integridade de pool mais precisa, se disponível.
*
* @param key Custom key for the health indicator component.
* @param appNameLike Optional pattern to match the application_name column.
*/
async checkPostgresPoolStats(
key: string = POSTGRES_HEALTH_KEY,
appNameLike: string = POSTGRES_APP_NAME_PATTERN,
): Promise<HealthIndicatorResult> {
try {
const query = `
SELECT
count(*) AS "totalConnections",
sum(CASE WHEN state = 'active' THEN 1 ELSE 0 END) AS "activeConnections",
sum(CASE WHEN state = 'idle' THEN 1 ELSE 0 END) AS "idleConnections",
sum(CASE WHEN state = 'idle in transaction' THEN 1 ELSE 0 END) AS "idleInTransactionConnections"
FROM
pg_stat_activity
WHERE
datname = current_database()
AND application_name LIKE $1
`;
const params = [appNameLike];
const results: {
totalConnections: string | number;
activeConnections: string | number;
idleConnections: string | number;
idleInTransactionConnections: string | number;
}[] = await this.postgresDataSource.query(query, params);
if (!results || results.length === 0) {
throw new Error('PostgreSQL pg_stat_activity query returned no results unexpectedly.');
}
const result = results[0];
const totalConnections = parseInt(String(result.totalConnections ?? 0), 10);
const activeConnections = parseInt(String(result.activeConnections ?? 0), 10);
const idleConnections = parseInt(String(result.idleConnections ?? 0), 10);
const idleInTransactionConnections = parseInt(String(result.idleInTransactionConnections ?? 0), 10);
// Validate parsing
if (isNaN(totalConnections) || isNaN(activeConnections) || isNaN(idleConnections) || isNaN(idleInTransactionConnections)) {
throw new Error('Failed to parse connection counts from PostgreSQL pg_stat_activity query result.');
}
const isHealthy = true;
const details = {
totalConnections,
activeConnections,
idleConnections,
idleInTransactionConnections,
applicationNamePattern: appNameLike,
};
return this.getStatus(key, isHealthy, details);
} catch (error) {
this.logger.error(`PostgreSQL pool stats check failed for key "${key}": ${error.message}`, error.stack);
throw new HealthCheckError(
`${key} check failed`,
this.getStatus(key, false, { message: error.message }),
);
}
}
/**
* Convenience method to run all pool checks defined in this indicator.
* You would typically call this from your main HealthController.
*/
async checkAllPools() : Promise<HealthIndicatorResult[]> {
const results = await Promise.allSettled([
this.checkOraclePoolStats(),
this.checkPostgresPoolStats()
]);
// Processa os resultados para se ajustar à estrutura do Terminus, se necessário, ou retorna diretamente
// Observações: Métodos individuais já retornam HealthIndicatorResult ou lançam HealthCheckError
// Este método pode não ser estritamente necessário se você chamar verificações individuais no controlador.
// Para simplificar, vamos supor que o controlador chama as verificações individuais.
// Se você quisesse que esse método retornasse um único status, precisaria de mais lógica.
// Relançar erros ou agregar status.
// Example: Log results (individual methods handle the Terminus return/error)
results.forEach(result => {
if (result.status === 'rejected') {
// Already logged and thrown as HealthCheckError inside the check methods
} else {
// Optionally log success details
this.logger.log(`Pool check successful: ${JSON.stringify(result.value)}`);
}
});
return results
.filter((r): r is PromiseFulfilledResult<HealthIndicatorResult> => r.status === 'fulfilled')
.map(r => r.value);
}
}

View File

@@ -1,52 +0,0 @@
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { InjectConnection } from '@nestjs/typeorm';
import { Connection, DataSource } from 'typeorm';
@Injectable()
export class TypeOrmHealthIndicator extends HealthIndicator {
constructor(
@InjectConnection('oracle') private oracleConnection: DataSource,
@InjectConnection('postgres') private postgresConnection: DataSource,
) {
super();
}
async checkOracle(): Promise<HealthIndicatorResult> {
const key = 'oracle';
try {
const isHealthy = this.oracleConnection.isInitialized;
const result = this.getStatus(key, isHealthy);
if (isHealthy) {
return result;
}
throw new HealthCheckError('Oracle healthcheck failed', result);
} catch (error) {
const result = this.getStatus(key, false, { message: error.message });
throw new HealthCheckError('Oracle healthcheck failed', result);
}
}
async checkPostgres(): Promise<HealthIndicatorResult> {
const key = 'postgres';
try {
const isHealthy = this.postgresConnection.isInitialized;
const result = this.getStatus(key, isHealthy);
if (isHealthy) {
return result;
}
throw new HealthCheckError('Postgres healthcheck failed', result);
} catch (error) {
const result = this.getStatus(key, false, { message: error.message });
throw new HealthCheckError('Postgres healthcheck failed', result);
}
}
}

View File

@@ -1,93 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Counter, Gauge, Histogram } from 'prom-client';
import { InjectConnection } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Injectable()
export class CustomMetricsService {
constructor(
@InjectMetric('http_request_total')
private readonly requestCounter: Counter<string>,
@InjectMetric('http_request_duration_seconds')
private readonly requestDuration: Histogram<string>,
@InjectMetric('api_memory_usage_bytes')
private readonly memoryGauge: Gauge<string>,
@InjectMetric('api_db_connection_pool_used')
private readonly dbPoolUsedGauge: Gauge<string>,
@InjectMetric('api_db_connection_pool_total')
private readonly dbPoolTotalGauge: Gauge<string>,
@InjectMetric('api_db_query_duration_seconds')
private readonly dbQueryDuration: Histogram<string>,
@InjectConnection('oracle')
private oracleConnection: DataSource,
@InjectConnection('postgres')
private postgresConnection: DataSource,
) {
// Iniciar coleta de métricas de memória
this.startMemoryMetrics();
// Iniciar coleta de métricas do pool de conexões
this.startDbPoolMetrics();
}
recordHttpRequest(method: string, route: string, statusCode: number): void {
this.requestCounter.inc({ method, route, statusCode: statusCode.toString() });
}
startTimingRequest(): (labels?: Record<string, string>) => void {
const end = this.requestDuration.startTimer();
return (labels?: Record<string, string>) => end(labels);
}
recordDbQueryDuration(db: 'oracle' | 'postgres', operation: string, durationMs: number): void {
this.dbQueryDuration.observe({ db, operation }, durationMs / 1000);
}
private startMemoryMetrics(): void {
// Coletar métricas de memória a cada 15 segundos
setInterval(() => {
const memoryUsage = process.memoryUsage();
this.memoryGauge.set({ type: 'rss' }, memoryUsage.rss);
this.memoryGauge.set({ type: 'heapTotal' }, memoryUsage.heapTotal);
this.memoryGauge.set({ type: 'heapUsed' }, memoryUsage.heapUsed);
this.memoryGauge.set({ type: 'external' }, memoryUsage.external);
}, 15000);
}
private startDbPoolMetrics(): void {
// Coletar métricas do pool de conexões a cada 15 segundos
setInterval(async () => {
try {
// Tente obter estatísticas do pool do Oracle
// Nota: depende da implementação específica do OracleDB
if (this.oracleConnection && this.oracleConnection.driver) {
const oraclePoolStats = (this.oracleConnection.driver as any).pool?.getStatistics?.();
if (oraclePoolStats) {
this.dbPoolUsedGauge.set({ db: 'oracle' }, oraclePoolStats.busy || 0);
this.dbPoolTotalGauge.set({ db: 'oracle' }, oraclePoolStats.poolMax || 0);
}
}
// Tente obter estatísticas do pool do Postgres
// Nota: depende da implementação específica do TypeORM
if (this.postgresConnection && this.postgresConnection.driver) {
const pgPoolStats = (this.postgresConnection.driver as any).pool;
if (pgPoolStats) {
this.dbPoolUsedGauge.set({ db: 'postgres' }, pgPoolStats.totalCount - pgPoolStats.idleCount || 0);
this.dbPoolTotalGauge.set({ db: 'postgres' }, pgPoolStats.totalCount || 0);
}
}
} catch (error) {
console.error('Erro ao coletar métricas do pool de conexões:', error);
}
}, 15000);
}
}

View File

@@ -1,51 +0,0 @@
import {
makeCounterProvider,
makeGaugeProvider,
makeHistogramProvider
} from '@willsoto/nestjs-prometheus';
export const metricProviders = [
// Contador de requisições HTTP
makeCounterProvider({
name: 'http_request_total',
help: 'Total de requisições HTTP',
labelNames: ['method', 'route', 'statusCode'],
}),
// Histograma de duração de requisições HTTP
makeHistogramProvider({
name: 'http_request_duration_seconds',
help: 'Duração das requisições HTTP em segundos',
labelNames: ['method', 'route', 'error'], // 👈 adicionado "error"
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10],
}),
// Gauge para uso de memória
makeGaugeProvider({
name: 'api_memory_usage_bytes',
help: 'Uso de memória da aplicação em bytes',
labelNames: ['type'],
}),
// Gauge para conexões de banco de dados usadas
makeGaugeProvider({
name: 'api_db_connection_pool_used',
help: 'Número de conexões de banco de dados em uso',
labelNames: ['db'],
}),
// Gauge para total de conexões no pool de banco de dados
makeGaugeProvider({
name: 'api_db_connection_pool_total',
help: 'Número total de conexões no pool de banco de dados',
labelNames: ['db'],
}),
// Histograma para duração de consultas de banco de dados
makeHistogramProvider({
name: 'api_db_query_duration_seconds',
help: 'Duração das consultas de banco de dados em segundos',
labelNames: ['db', 'operation'],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2],
}),
];

View File

@@ -1,64 +0,0 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CustomMetricsService } from './custom.metrics';
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
constructor(private metricsService: CustomMetricsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
if (context.getType() !== 'http') {
return next.handle();
}
const request = context.switchToHttp().getRequest();
const { method, url } = request;
// Simplificar a rota para evitar cardinalidade alta no Prometheus
// Ex: /users/123 -> /users/:id
const route = this.normalizeRoute(url);
// Inicia o timer para medir a duração da requisição
const endTimer = this.metricsService.startTimingRequest();
return next.handle().pipe(
tap({
next: (data) => {
const response = context.switchToHttp().getResponse();
const statusCode = response.statusCode;
// Registra a requisição concluída
this.metricsService.recordHttpRequest(method, route, statusCode);
// Finaliza o timer com labels adicionais
endTimer({ method, route });
},
error: (error) => {
// Determina o código de status do erro
const statusCode = error.status || 500;
// Registra a requisição com erro
this.metricsService.recordHttpRequest(method, route, statusCode);
// Finaliza o timer com labels adicionais
endTimer({ method, route, error: 'true' });
}
})
);
}
private normalizeRoute(url: string): string {
// Remove query parameters
const path = url.split('?')[0];
// Normaliza rotas com IDs e outros parâmetros dinâmicos
// Por exemplo, /users/123 -> /users/:id
return path.replace(/\/[0-9a-f]{8,}|\/[0-9]+/g, '/:id');
}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/controllers#controllers
*/

View File

@@ -1,5 +1,5 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { LogisticController } from './logistic.controller';
import { LogisticService } from './logistic.service';

View File

@@ -1,4 +1,8 @@
import { Get, HttpException, HttpStatus, Injectable, Query, UseGuards } from '@nestjs/common';
import {
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { createOracleConfig } from '../core/configs/typeorm.oracle.config';
import { createPostgresConfig } from '../core/configs/typeorm.postgres.config';
import { CarOutDelivery } from '../core/models/car-out-delivery.model';
@@ -16,7 +20,6 @@ export class LogisticService {
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sqlWMS = `select dados.*,
( select count(distinct v.numero_carga) quantidade_cargas_embarcadas
from volume v, carga c2
@@ -52,7 +55,6 @@ export class LogisticService {
where dados.data_saida >= current_date
ORDER BY dados.data_saida desc `;
const sql = `SELECT COUNT(DISTINCT PCCARREG.NUMCAR) as "qtde"
,SUM(PCPEDI.QT * PCPRODUT.PESOBRUTO) as "totalKG"
,SUM(CASE WHEN PCPEDC.DTINICIALSEP IS NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_nao_iniciado"
@@ -86,7 +88,11 @@ export class LogisticService {
console.log(amanha);
console.log(JSON.stringify(mov));
const movFiltered = mov.filter((m) => m.data_saida.toISOString().split('T')[0] == amanha.toISOString().split('T')[0]);
const movFiltered = mov.filter(
(m) =>
m.data_saida.toISOString().split('T')[0] ==
amanha.toISOString().split('T')[0],
);
return movFiltered;
} catch (e) {
@@ -103,7 +109,6 @@ export class LogisticService {
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT PCCARREG.NUMCAR as "id"
,PCCARREG.DTSAIDA as "createDate"
,PCCARREG.DESTINO as "comment"
@@ -138,7 +143,6 @@ export class LogisticService {
await queryRunner.release();
await dataSource.destroy();
}
}
async getStatusCar(placa: string) {
@@ -147,7 +151,6 @@ export class LogisticService {
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT ESTSAIDAVEICULO.CODSAIDA FROM ESTSAIDAVEICULO, PCVEICUL
WHERE ESTSAIDAVEICULO.CODVEICULO = PCVEICUL.CODVEICULO
AND PCVEICUL.PLACA = '${placa}'
@@ -155,8 +158,7 @@ export class LogisticService {
const outCar = await queryRunner.manager.query(sql);
return { veiculoEmViagem: ( outCar.length > 0 ) ? true : false };
return { veiculoEmViagem: outCar.length > 0 ? true : false };
} catch (e) {
console.log(e);
} finally {
@@ -188,14 +190,12 @@ export class LogisticService {
}
async createCarOut(data: CarOutDelivery) {
const dataSource = new DataSource(createPostgresConfig(this.configService));
await dataSource.initialize();
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const sqlSequence = `SELECT ESS_SAIDAVEICULO.NEXTVAL as "id" FROM DUAL`;
const dataSequence = await queryRunner.query(sqlSequence);
let i = 0;
@@ -207,7 +207,7 @@ export class LogisticService {
const image3 = '';
const image4 = '';
data.helpers.forEach(helper => {
data.helpers.forEach((helper) => {
switch (i) {
case 0:
helperId1 = helper.id;
@@ -246,13 +246,11 @@ export class LogisticService {
,KMINICIAL = ${data.startKm}
WHERE NUMCAR = ${data.numberLoading[y]}`;
await queryRunner.query(sql);
}
await queryRunner.commitTransaction();
return { message: 'Dados da saída de veículo gravada com sucesso!'}
return { message: 'Dados da saída de veículo gravada com sucesso!' };
} catch (e) {
await queryRunner.rollbackTransaction();
throw e;
@@ -263,14 +261,12 @@ export class LogisticService {
}
async createCarIn(data: CarInDelivery) {
const dataSource = new DataSource(createPostgresConfig(this.configService));
await dataSource.initialize();
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const sqlOutCar = `SELECT ESTSAIDAVEICULO.CODSAIDA as "id"
FROM PCCARREG, PCVEICUL, ESTSAIDAVEICULO, ESTSAIDAVEICULOCARREG
WHERE PCCARREG.CODVEICULO = PCVEICUL.CODVEICULO
@@ -281,7 +277,10 @@ export class LogisticService {
const dataOutCar = await queryRunner.query(sqlOutCar);
if (dataOutCar.length == 0) {
throw new HttpException('Não foi localiza viagens em aberto para este veículo.', HttpStatus.BAD_REQUEST );
throw new HttpException(
'Não foi localiza viagens em aberto para este veículo.',
HttpStatus.BAD_REQUEST,
);
}
const i = 0;
@@ -332,8 +331,7 @@ export class LogisticService {
await queryRunner.commitTransaction();
return { message: 'Dados de retorno do veículo gravada com sucesso!'}
return { message: 'Dados de retorno do veículo gravada com sucesso!' };
} catch (e) {
await queryRunner.rollbackTransaction();
console.log(e);
@@ -343,5 +341,4 @@ export class LogisticService {
await dataSource.destroy();
}
}
}

View File

@@ -15,18 +15,25 @@ async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.use(helmet({
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: [`'self'`],
scriptSrc: [`'self'`, `'unsafe-inline'`, 'cdn.jsdelivr.net', 'cdnjs.cloudflare.com'],
scriptSrc: [
`'self'`,
`'unsafe-inline'`,
'cdn.jsdelivr.net',
'cdnjs.cloudflare.com',
],
styleSrc: [`'self'`, `'unsafe-inline'`, 'cdnjs.cloudflare.com'],
imgSrc: [`'self'`, 'data:'],
connectSrc: [`'self'`],
fontSrc: [`'self'`, 'cdnjs.cloudflare.com'],
},
},
}));
}),
);
// Configurar pasta de arquivos estáticos
app.useStaticAssets(join(__dirname, '..', 'public'), {
@@ -56,7 +63,6 @@ async function bootstrap() {
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
});
const config = new DocumentBuilder()
.setTitle('Portal Jurunense API')
.setDescription('Documentação da API do Portal Jurunense')
@@ -68,7 +74,5 @@ async function bootstrap() {
SwaggerModule.setup('docs', app, document);
await app.listen(8066);
}
bootstrap();

View File

@@ -38,7 +38,7 @@ export class CreatePaymentDto {
@ApiProperty({
description: 'Valor do pagamento',
example: 1000.00,
example: 1000.0,
required: true,
})
amount: number;

View File

@@ -69,7 +69,7 @@ export class OrderDto {
@ApiProperty({
description: 'Valor total do pedido',
example: 1000.00,
example: 1000.0,
})
amount: number;
@@ -81,7 +81,7 @@ export class OrderDto {
@ApiProperty({
description: 'Valor total pago',
example: 1000.00,
example: 1000.0,
})
amountPaid: number;

View File

@@ -39,7 +39,7 @@ export class PaymentDto {
@ApiProperty({
description: 'Valor do pagamento',
example: 1000.00,
example: 1000.0,
})
amount: number;

View File

@@ -1,5 +1,11 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiParam,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { OrdersPaymentService } from './orders-payment.service';
import { OrderDto } from './dto/order.dto';
import { PaymentDto } from './dto/payment.dto';
@@ -12,7 +18,6 @@ import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
@UseGuards(JwtAuthGuard)
@Controller('api/v1/orders-payment')
export class OrdersPaymentController {
constructor(private readonly orderPaymentService: OrdersPaymentService) {}
@Get('orders/:id')
@@ -21,7 +26,7 @@ export class OrdersPaymentController {
@ApiResponse({
status: 200,
description: 'Lista de pedidos retornada com sucesso',
type: [OrderDto]
type: [OrderDto],
})
async findOrders(@Param('id') storeId: string): Promise<OrderDto[]> {
return this.orderPaymentService.findOrders(storeId, 0);
@@ -34,7 +39,7 @@ export class OrdersPaymentController {
@ApiResponse({
status: 200,
description: 'Pedido retornado com sucesso',
type: OrderDto
type: OrderDto,
})
async findOrder(
@Param('id') storeId: string,
@@ -50,7 +55,7 @@ export class OrdersPaymentController {
@ApiResponse({
status: 200,
description: 'Lista de pagamentos retornada com sucesso',
type: [PaymentDto]
type: [PaymentDto],
})
async findPayments(@Param('id') orderId: number): Promise<PaymentDto[]> {
return this.orderPaymentService.findPayments(orderId);
@@ -59,7 +64,7 @@ export class OrdersPaymentController {
@ApiOperation({ summary: 'Cria um novo pagamento' })
@ApiResponse({
status: 201,
description: 'Pagamento criado com sucesso'
description: 'Pagamento criado com sucesso',
})
async createPayment(@Body() data: CreatePaymentDto): Promise<void> {
return this.orderPaymentService.createPayment(data);
@@ -69,7 +74,7 @@ export class OrdersPaymentController {
@ApiOperation({ summary: 'Cria uma nova fatura' })
@ApiResponse({
status: 201,
description: 'Fatura criada com sucesso'
description: 'Fatura criada com sucesso',
})
async createInvoice(@Body() data: CreateInvoiceDto): Promise<void> {
return this.orderPaymentService.createInvoice(data);

View File

@@ -1,5 +1,5 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
https://docs.nestjs.com/modules

View File

@@ -11,7 +11,7 @@ import { CreateInvoiceDto } from './dto/create-invoice.dto';
export class OrdersPaymentService {
constructor(
private readonly configService: ConfigService,
@Inject(DATA_SOURCE) private readonly dataSource: DataSource
@Inject(DATA_SOURCE) private readonly dataSource: DataSource,
) {}
async findOrders(storeId: string, orderId: number): Promise<OrderDto[]> {
@@ -48,7 +48,7 @@ export class OrdersPaymentService {
}
const orders = await queryRunner.manager.query(sql + sqlWhere);
return orders.map(order => new OrderDto(order));
return orders.map((order) => new OrderDto(order));
} finally {
await queryRunner.release();
}
@@ -73,7 +73,7 @@ export class OrdersPaymentService {
WHERE ESTPAGAMENTO.NUMORCA = ${orderId}`;
const payments = await queryRunner.manager.query(sql);
return payments.map(payment => new PaymentDto(payment));
return payments.map((payment) => new PaymentDto(payment));
} finally {
await queryRunner.release();
}

View File

@@ -0,0 +1,40 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DebService } from '../deb.service';
import { DebRepository } from '../../repositories/deb.repository';
export const createMockRepository = (
methods: Partial<DebRepository> = {},
) =>
({
findByCpfCgcent: jest.fn(),
...methods,
} as any);
export interface DebServiceTestContext {
service: DebService;
mockRepository: jest.Mocked<DebRepository>;
}
export async function createDebServiceTestModule(
repositoryMethods: Partial<DebRepository> = {},
): Promise<DebServiceTestContext> {
const mockRepository = createMockRepository(repositoryMethods);
const module: TestingModule = await Test.createTestingModule({
providers: [
DebService,
{
provide: DebRepository,
useValue: mockRepository,
},
],
}).compile();
const service = module.get<DebService>(DebService);
return {
service,
mockRepository,
};
}

View File

@@ -0,0 +1,191 @@
import { createDebServiceTestModule } from './deb.service.spec.helper';
import { DebDto } from '../../dto/DebDto';
describe('DebService', () => {
describe('findByCpfCgcent', () => {
let context: Awaited<ReturnType<typeof createDebServiceTestModule>>;
beforeEach(async () => {
context = await createDebServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
it('deve buscar débitos por CPF/CGCENT com sucesso', async () => {
const mockDebs: DebDto[] = [
{
dtemissao: new Date('2024-01-15'),
codfilial: '1',
duplic: '12345',
prest: '1',
codcli: 1000,
cliente: 'JOÃO DA SILVA',
codcob: 'BL',
cobranca: 'BOLETO',
dtvenc: new Date('2024-02-15'),
dtpag: null,
valor: 150.5,
situacao: 'A VENCER',
},
{
dtemissao: new Date('2024-01-20'),
codfilial: '1',
duplic: '12346',
prest: '2',
codcli: 1000,
cliente: 'JOÃO DA SILVA',
codcob: 'BL',
cobranca: 'BOLETO',
dtvenc: new Date('2024-02-20'),
dtpag: new Date('2024-02-10'),
valor: 200.0,
situacao: 'PAGO',
},
];
context.mockRepository.findByCpfCgcent.mockResolvedValue(mockDebs);
const result = await context.service.findByCpfCgcent('12345678900');
expect(result).toHaveLength(2);
expect(result[0].codcli).toBe(1000);
expect(result[0].cliente).toBe('JOÃO DA SILVA');
expect(result[0].situacao).toBe('A VENCER');
expect(result[1].situacao).toBe('PAGO');
expect(context.mockRepository.findByCpfCgcent).toHaveBeenCalledWith(
'12345678900',
undefined,
undefined,
);
});
it('deve buscar débitos com matricula informada', async () => {
const mockDebs: DebDto[] = [
{
dtemissao: new Date('2024-01-15'),
codfilial: '1',
duplic: '12345',
prest: '1',
codcli: 1000,
cliente: 'JOÃO DA SILVA',
codcob: 'BL',
cobranca: 'BOLETO',
dtvenc: new Date('2024-02-15'),
dtpag: null,
valor: 150.5,
situacao: 'A VENCER',
},
];
context.mockRepository.findByCpfCgcent.mockResolvedValue(mockDebs);
const result = await context.service.findByCpfCgcent(
'12345678900',
1498,
);
expect(result).toHaveLength(1);
expect(context.mockRepository.findByCpfCgcent).toHaveBeenCalledWith(
'12345678900',
1498,
undefined,
);
});
it('deve buscar débitos com cobranca informada', async () => {
const mockDebs: DebDto[] = [
{
dtemissao: new Date('2024-01-15'),
codfilial: '1',
duplic: '12345',
prest: '1',
codcli: 1000,
cliente: 'JOÃO DA SILVA',
codcob: 'BL',
cobranca: 'BOLETO',
dtvenc: new Date('2024-02-15'),
dtpag: null,
valor: 150.5,
situacao: 'A VENCER',
},
];
context.mockRepository.findByCpfCgcent.mockResolvedValue(mockDebs);
const result = await context.service.findByCpfCgcent(
'12345678900',
undefined,
'BL',
);
expect(result).toHaveLength(1);
expect(context.mockRepository.findByCpfCgcent).toHaveBeenCalledWith(
'12345678900',
undefined,
'BL',
);
});
it('deve buscar débitos com matricula e cobranca informadas', async () => {
const mockDebs: DebDto[] = [
{
dtemissao: new Date('2024-01-15'),
codfilial: '1',
duplic: '12345',
prest: '1',
codcli: 1000,
cliente: 'JOÃO DA SILVA',
codcob: 'BL',
cobranca: 'BOLETO',
dtvenc: new Date('2024-02-15'),
dtpag: null,
valor: 150.5,
situacao: 'A VENCER',
},
];
context.mockRepository.findByCpfCgcent.mockResolvedValue(mockDebs);
const result = await context.service.findByCpfCgcent(
'12345678900',
1498,
'BL',
);
expect(result).toHaveLength(1);
expect(context.mockRepository.findByCpfCgcent).toHaveBeenCalledWith(
'12345678900',
1498,
'BL',
);
});
it('deve retornar array vazio quando nenhum débito é encontrado', async () => {
context.mockRepository.findByCpfCgcent.mockResolvedValue([]);
const result = await context.service.findByCpfCgcent('99999999999');
expect(result).toHaveLength(0);
expect(Array.isArray(result)).toBe(true);
expect(context.mockRepository.findByCpfCgcent).toHaveBeenCalledWith(
'99999999999',
undefined,
undefined,
);
});
it('deve propagar erro do repositório', async () => {
const repositoryError = new Error('Database connection failed');
context.mockRepository.findByCpfCgcent.mockRejectedValue(
repositoryError,
);
await expect(
context.service.findByCpfCgcent('12345678900'),
).rejects.toThrow('Database connection failed');
});
});
});

View File

@@ -0,0 +1,60 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from '../orders.service';
import { OrdersRepository } from '../../repositories/orders.repository';
import { IRedisClient } from '../../../core/configs/cache/IRedisClient';
import { RedisClientToken } from '../../../core/configs/cache/redis-client.adapter.provider';
export const createMockRepository = (
methods: Partial<OrdersRepository> = {},
) =>
({
findOrders: jest.fn(),
getCompletedDeliveries: jest.fn(),
...methods,
} as any);
export const createMockRedisClient = () =>
({
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
} as any);
export interface OrdersServiceTestContext {
service: OrdersService;
mockRepository: jest.Mocked<OrdersRepository>;
mockRedisClient: jest.Mocked<IRedisClient>;
}
export async function createOrdersServiceTestModule(
repositoryMethods: Partial<OrdersRepository> = {},
redisClientMethods: Partial<IRedisClient> = {},
): Promise<OrdersServiceTestContext> {
const mockRepository = createMockRepository(repositoryMethods);
const mockRedisClient = {
...createMockRedisClient(),
...redisClientMethods,
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{
provide: OrdersRepository,
useValue: mockRepository,
},
{
provide: RedisClientToken,
useValue: mockRedisClient,
},
],
}).compile();
const service = module.get<OrdersService>(OrdersService);
return {
service,
mockRepository,
mockRedisClient,
};
}

View File

@@ -0,0 +1,303 @@
import { createOrdersServiceTestModule } from './orders.service.spec.helper';
import { FindOrdersDto } from '../../dto/find-orders.dto';
import { OrderResponseDto } from '../../dto/order-response.dto';
import { DeliveryCompleted } from '../../dto/delivery-completed.dto';
describe('OrdersService', () => {
describe('findOrders', () => {
let context: Awaited<ReturnType<typeof createOrdersServiceTestModule>>;
beforeEach(async () => {
context = await createOrdersServiceTestModule();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Tests that expose problems', () => {
it('should return orders from repository when cache is empty', async () => {
const query: FindOrdersDto = {
codfilial: '1',
};
const mockOrders: OrderResponseDto[] = [
{
orderId: 1,
invoiceNumber: 12345,
customerName: 'Cliente 1',
} as OrderResponseDto,
{
orderId: 2,
invoiceNumber: 12346,
customerName: 'Cliente 2',
} as OrderResponseDto,
];
context.mockRedisClient.get.mockResolvedValue(null);
context.mockRepository.findOrders.mockResolvedValue(mockOrders);
const result = await context.service.findOrders(query);
expect(result).toEqual(mockOrders);
expect(context.mockRepository.findOrders).toHaveBeenCalledWith(query);
expect(context.mockRedisClient.get).toHaveBeenCalled();
expect(context.mockRedisClient.set).toHaveBeenCalled();
});
it('should return cached orders when cache exists', async () => {
const query: FindOrdersDto = {
codfilial: '1',
};
const cachedOrders: OrderResponseDto[] = [
{
orderId: 1,
invoiceNumber: 12345,
customerName: 'Cliente 1',
} as OrderResponseDto,
];
context.mockRedisClient.get.mockResolvedValue(cachedOrders);
const result = await context.service.findOrders(query);
expect(result).toEqual(cachedOrders);
expect(context.mockRepository.findOrders).not.toHaveBeenCalled();
expect(context.mockRedisClient.set).not.toHaveBeenCalled();
});
it('should return orders without completed deliveries when includeCompletedDeliveries is false', async () => {
const query: FindOrdersDto = {
codfilial: '1',
includeCompletedDeliveries: false,
};
const mockOrders: OrderResponseDto[] = [
{
orderId: 1,
invoiceNumber: 12345,
customerName: 'Cliente 1',
} as OrderResponseDto,
];
context.mockRedisClient.get.mockResolvedValue(null);
context.mockRepository.findOrders.mockResolvedValue(mockOrders);
const result = await context.service.findOrders(query);
expect(result).toEqual(mockOrders);
expect(context.mockRepository.getCompletedDeliveries).not.toHaveBeenCalled();
expect(result[0].completedDeliveries).toBeUndefined();
});
it('should include completed deliveries when includeCompletedDeliveries is true', async () => {
const query: FindOrdersDto = {
codfilial: '1',
includeCompletedDeliveries: true,
};
const mockOrders: OrderResponseDto[] = [
{
orderId: 1,
invoiceNumber: 12345,
customerName: 'Cliente 1',
} as OrderResponseDto,
{
orderId: 2,
invoiceNumber: 12346,
customerName: 'Cliente 2',
} as OrderResponseDto,
];
const mockDeliveries1: DeliveryCompleted[] = [
{
outId: 1,
transactionId: 100,
invoiceNumber: '12345',
customerName: 'Cliente 1',
} as DeliveryCompleted,
];
const mockDeliveries2: DeliveryCompleted[] = [
{
outId: 2,
transactionId: 101,
invoiceNumber: '12346',
customerName: 'Cliente 2',
} as DeliveryCompleted,
];
context.mockRedisClient.get.mockResolvedValue(null);
context.mockRepository.findOrders.mockResolvedValue(mockOrders);
context.mockRepository.getCompletedDeliveries
.mockResolvedValueOnce(mockDeliveries1)
.mockResolvedValueOnce(mockDeliveries2);
const result = await context.service.findOrders(query);
expect(result).toHaveLength(2);
expect(result[0].completedDeliveries).toEqual(mockDeliveries1);
expect(result[1].completedDeliveries).toEqual(mockDeliveries2);
expect(context.mockRepository.getCompletedDeliveries).toHaveBeenCalledTimes(2);
expect(context.mockRepository.getCompletedDeliveries).toHaveBeenCalledWith({
orderNumber: 12345,
limit: 10,
offset: 0,
});
expect(context.mockRepository.getCompletedDeliveries).toHaveBeenCalledWith({
orderNumber: 12346,
limit: 10,
offset: 0,
});
});
it('should set completedDeliveries to empty array when getCompletedDeliveries throws error', async () => {
const query: FindOrdersDto = {
codfilial: '1',
includeCompletedDeliveries: true,
};
const mockOrders: OrderResponseDto[] = [
{
orderId: 1,
invoiceNumber: 12345,
customerName: 'Cliente 1',
} as OrderResponseDto,
];
context.mockRedisClient.get.mockResolvedValue(null);
context.mockRepository.findOrders.mockResolvedValue(mockOrders);
context.mockRepository.getCompletedDeliveries.mockRejectedValue(
new Error('Database error'),
);
const result = await context.service.findOrders(query);
expect(result).toHaveLength(1);
expect(result[0].completedDeliveries).toEqual([]);
expect(context.mockRepository.getCompletedDeliveries).toHaveBeenCalled();
});
it('should handle empty orders array', async () => {
const query: FindOrdersDto = {
codfilial: '1',
};
context.mockRedisClient.get.mockResolvedValue(null);
context.mockRepository.findOrders.mockResolvedValue([]);
const result = await context.service.findOrders(query);
expect(result).toEqual([]);
expect(context.mockRepository.findOrders).toHaveBeenCalledWith(query);
});
it('should handle orders with includeCompletedDeliveries when some deliveries fail', async () => {
const query: FindOrdersDto = {
codfilial: '1',
includeCompletedDeliveries: true,
};
const mockOrders: OrderResponseDto[] = [
{
orderId: 1,
invoiceNumber: 12345,
customerName: 'Cliente 1',
} as OrderResponseDto,
{
orderId: 2,
invoiceNumber: 12346,
customerName: 'Cliente 2',
} as OrderResponseDto,
];
const mockDeliveries1: DeliveryCompleted[] = [
{
outId: 1,
transactionId: 100,
invoiceNumber: '12345',
customerName: 'Cliente 1',
} as DeliveryCompleted,
];
context.mockRedisClient.get.mockResolvedValue(null);
context.mockRepository.findOrders.mockResolvedValue(mockOrders);
context.mockRepository.getCompletedDeliveries
.mockResolvedValueOnce(mockDeliveries1)
.mockRejectedValueOnce(new Error('Database error'));
const result = await context.service.findOrders(query);
expect(result).toHaveLength(2);
expect(result[0].completedDeliveries).toEqual(mockDeliveries1);
expect(result[1].completedDeliveries).toEqual([]);
});
it('should use correct cache key based on query parameters', async () => {
const query: FindOrdersDto = {
codfilial: '1',
customerId: 123,
};
const mockOrders: OrderResponseDto[] = [];
context.mockRedisClient.get.mockResolvedValue(null);
context.mockRepository.findOrders.mockResolvedValue(mockOrders);
await context.service.findOrders(query);
expect(context.mockRedisClient.get).toHaveBeenCalled();
const cacheKey = context.mockRedisClient.get.mock.calls[0][0];
expect(cacheKey).toContain('orders:query:');
});
it('should handle orders without invoiceNumber when includeCompletedDeliveries is true', async () => {
const query: FindOrdersDto = {
codfilial: '1',
includeCompletedDeliveries: true,
};
const mockOrders: OrderResponseDto[] = [
{
orderId: 1,
invoiceNumber: null,
customerName: 'Cliente 1',
} as any,
];
context.mockRedisClient.get.mockResolvedValue(null);
context.mockRepository.findOrders.mockResolvedValue(mockOrders);
context.mockRepository.getCompletedDeliveries.mockResolvedValue([]);
const result = await context.service.findOrders(query);
expect(result).toHaveLength(1);
expect(context.mockRepository.getCompletedDeliveries).toHaveBeenCalledWith({
orderNumber: null,
limit: 10,
offset: 0,
});
});
it('should validate that cache TTL is set correctly', async () => {
const query: FindOrdersDto = {
codfilial: '1',
};
const mockOrders: OrderResponseDto[] = [];
context.mockRedisClient.get.mockResolvedValue(null);
context.mockRepository.findOrders.mockResolvedValue(mockOrders);
await context.service.findOrders(query);
expect(context.mockRedisClient.set).toHaveBeenCalled();
const setCall = context.mockRedisClient.set.mock.calls[0];
const ttl = setCall[2];
expect(ttl).toBe(60);
});
});
});
});

View File

@@ -4,23 +4,17 @@ import { DebDto } from '../dto/DebDto';
@Injectable()
export class DebService {
constructor(
private readonly debRepository: DebRepository,
) {}
constructor(private readonly debRepository: DebRepository) {}
/**
* Busca débitos por CPF ou CGCENT
* @param cpfCgcent - CPF ou CGCENT do cliente (validado pelo DTO)
* @param matricula - Matrícula do funcionário (opcional)
* @param cobranca - Código de cobrança (opcional)
* @returns Lista de débitos do cliente
* @throws {Error} Erro ao buscar débitos no banco de dados
*/
async findByCpfCgcent(
cpfCgcent: string,
matricula?: number,
cobranca?: string,
): Promise<DebDto[]> {
return await this.debRepository.findByCpfCgcent(cpfCgcent, matricula, cobranca);
return await this.debRepository.findByCpfCgcent(
cpfCgcent,
matricula,
cobranca,
);
}
}

View File

@@ -17,14 +17,16 @@ import { LeadtimeDto } from '../dto/leadtime.dto';
import { HttpException } from '@nestjs/common/exceptions/http.exception';
import { CarrierDto } from '../../data-consult/dto/carrier.dto';
import { MarkData } from '../interface/markdata';
import { EstLogTransferFilterDto, EstLogTransferResponseDto } from '../dto/estlogtransfer.dto';
import {
EstLogTransferFilterDto,
EstLogTransferResponseDto,
} from '../dto/estlogtransfer.dto';
import { DeliveryCompletedQuery } from '../dto/delivery-completed-query.dto';
import { DeliveryCompleted } from '../dto/delivery-completed.dto';
import { OrderResponseDto } from '../dto/order-response.dto';
@Injectable()
export class OrdersService {
// Cache TTL em segundos
private static readonly DEFAULT_TTL = 60;
private readonly TTL_ORDERS = OrdersService.DEFAULT_TTL;
private readonly TTL_INVOICE = OrdersService.DEFAULT_TTL;
@@ -42,19 +44,10 @@ export class OrdersService {
@Inject(RedisClientToken) private readonly redisClient: IRedisClient,
) {}
/**
* Buscar pedidos com cache baseado nos filtros
* @param query - Filtros para busca de pedidos
* @returns Lista de pedidos
*/
async findOrders(query: FindOrdersDto): Promise<OrderResponseDto[]> {
const key = `orders:query:${this.hashObject(query)}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_ORDERS,
async () => {
return getOrSetCache(this.redisClient, key, this.TTL_ORDERS, async () => {
const orders = await this.ordersRepository.findOrders(query);
if (!query.includeCompletedDeliveries) {
@@ -65,57 +58,43 @@ export class OrdersService {
const deliveryQuery = {
orderNumber: order.invoiceNumber,
limit: 10,
offset: 0
offset: 0,
};
try {
const deliveries = await this.ordersRepository.getCompletedDeliveries(deliveryQuery);
const deliveries = await this.ordersRepository.getCompletedDeliveries(
deliveryQuery,
);
order.completedDeliveries = deliveries;
} catch (error) {
// Se houver erro, definir como array vazio
order.completedDeliveries = [];
}
}
return orders;
},
);
});
}
/**
* Buscar pedidos por data de entrega com cache
* @param query - Filtros para busca por data de entrega
* @returns Lista de pedidos
*/
async findOrdersByDeliveryDate(query: FindOrdersByDeliveryDateDto): Promise<OrderResponseDto[]> {
async findOrdersByDeliveryDate(
query: FindOrdersByDeliveryDateDto,
): Promise<OrderResponseDto[]> {
const key = `orders:delivery:${this.hashObject(query)}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_ORDERS,
() => this.ordersRepository.findOrdersByDeliveryDate(query),
return getOrSetCache(this.redisClient, key, this.TTL_ORDERS, () =>
this.ordersRepository.findOrdersByDeliveryDate(query),
);
}
/**
* Buscar pedidos com resultados de fechamento de caixa
* @param query - Filtros para busca de pedidos
* @returns Lista de pedidos com dados de fechamento de caixa
*/
async findOrdersWithCheckout(query: FindOrdersDto): Promise<(OrderResponseDto & { checkout: any })[]> {
async findOrdersWithCheckout(
query: FindOrdersDto,
): Promise<(OrderResponseDto & { checkout: any })[]> {
const key = `orders:checkout:${this.hashObject(query)}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_ORDERS,
async () => {
// Primeiro obtém a lista de pedidos
return getOrSetCache(this.redisClient, key, this.TTL_ORDERS, async () => {
const orders = await this.findOrders(query);
// Para cada pedido, busca o fechamento de caixa
const results = await Promise.all(
orders.map(async order => {
orders.map(async (order) => {
try {
const checkout = await this.ordersRepository.findOrderWithCheckoutByOrder(
const checkout =
await this.ordersRepository.findOrderWithCheckoutByOrder(
Number(order.orderId),
);
return { ...order, checkout };
@@ -125,29 +104,25 @@ export class OrdersService {
}),
);
return results;
}
);
});
}
async getOrderCheckout(orderId: number) {
const key = `orders:checkout:${orderId}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_ORDERS,
async () => {
const result = await this.ordersRepository.findOrderWithCheckoutByOrder(orderId);
return getOrSetCache(this.redisClient, key, this.TTL_ORDERS, async () => {
const result = await this.ordersRepository.findOrderWithCheckoutByOrder(
orderId,
);
if (!result) {
throw new HttpException('Nenhum fechamento encontrado', HttpStatus.NOT_FOUND);
}
return result;
}
throw new HttpException(
'Nenhum fechamento encontrado',
HttpStatus.NOT_FOUND,
);
}
return result;
});
}
/**
* Buscar nota fiscal por chave NFe com cache
*/
async findInvoice(chavenfe: string): Promise<InvoiceDto> {
const key = `orders:invoice:${chavenfe}`;
@@ -172,16 +147,13 @@ export class OrdersService {
});
}
/**
* Buscar itens de pedido com cache
*/
async getItens(orderId: string): Promise<OrderItemDto[]> {
const key = `orders:itens:${orderId}`;
return getOrSetCache(this.redisClient, key, this.TTL_ITENS, async () => {
const itens = await this.ordersRepository.getItens(orderId);
return itens.map(item => ({
return itens.map((item) => ({
productId: Number(item.productId),
description: item.description,
pacth: item.pacth,
@@ -198,20 +170,14 @@ export class OrdersService {
});
}
/**
* Buscar entregas do pedido com cache
*/
async getOrderDeliveries(
orderId: string,
query: { createDateIni: string; createDateEnd: string },
): Promise<OrderDeliveryDto[]> {
const key = `orders:deliveries:${orderId}:${query.createDateIni}:${query.createDateEnd}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_DELIVERIES,
() => this.ordersRepository.getOrderDeliveries(orderId, query),
return getOrSetCache(this.redisClient, key, this.TTL_DELIVERIES, () =>
this.ordersRepository.getOrderDeliveries(orderId),
);
}
@@ -221,7 +187,7 @@ export class OrdersService {
return getOrSetCache(this.redisClient, key, this.TTL_ITENS, async () => {
const itens = await this.ordersRepository.getCutItens(orderId);
return itens.map(item => ({
return itens.map((item) => ({
productId: Number(item.productId),
description: item.description,
pacth: item.pacth,
@@ -233,7 +199,10 @@ export class OrdersService {
});
}
async getOrderDelivery(orderId: string, includeCompletedDeliveries: boolean = false): Promise<OrderDeliveryDto> {
async getOrderDelivery(
orderId: string,
includeCompletedDeliveries: boolean = false,
): Promise<OrderDeliveryDto> {
const key = `orders:delivery:${orderId}:${includeCompletedDeliveries}`;
return getOrSetCache(
@@ -241,7 +210,9 @@ export class OrdersService {
key,
this.TTL_DELIVERIES,
async () => {
const orderDelivery = await this.ordersRepository.getOrderDelivery(orderId);
const orderDelivery = await this.ordersRepository.getOrderDelivery(
orderId,
);
if (!orderDelivery) {
return null;
@@ -252,8 +223,8 @@ export class OrdersService {
}
try {
// Buscar entregas realizadas usando o transactionId do pedido
const transactionId = await this.ordersRepository.getOrderTransactionId(orderId);
const transactionId =
await this.ordersRepository.getOrderTransactionId(orderId);
if (!transactionId) {
orderDelivery.completedDeliveries = [];
@@ -263,31 +234,27 @@ export class OrdersService {
const deliveryQuery = {
transactionId: transactionId,
limit: 10,
offset: 0
offset: 0,
};
const deliveries = await this.ordersRepository.getCompletedDeliveriesByTransactionId(deliveryQuery);
const deliveries =
await this.ordersRepository.getCompletedDeliveriesByTransactionId(
deliveryQuery,
);
orderDelivery.completedDeliveries = deliveries;
} catch (error) {
// Se houver erro, definir como array vazio
orderDelivery.completedDeliveries = [];
}
return orderDelivery;
}
},
);
}
/**
* Buscar leadtime do pedido com cache
*/
async getLeadtime(orderId: string): Promise<LeadtimeDto[]> {
const key = `orders:leadtime:${orderId}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_LEADTIME,
() => this.ordersRepository.getLeadtimeWMS(orderId)
return getOrSetCache(this.redisClient, key, this.TTL_LEADTIME, () =>
this.ordersRepository.getLeadtimeWMS(orderId),
);
}
@@ -299,25 +266,21 @@ export class OrdersService {
);
}
/**
* Buscar log de transferência por ID do pedido com cache
*/
async getTransferLog(
orderId: number,
filters?: EstLogTransferFilterDto
filters?: EstLogTransferFilterDto,
): Promise<EstLogTransferResponseDto[] | null> {
const key = `orders:transfer-log:${orderId}:${this.hashObject(filters || {})}`;
const key = `orders:transfer-log:${orderId}:${this.hashObject(
filters || {},
)}`;
return getOrSetCache(this.redisClient, key, this.TTL_TRANSFER, () =>
this.ordersRepository.estlogtransfer(orderId, filters),
);
}
/**
* Buscar logs de transferência com filtros (sem especificar pedido específico)
*/
async getTransferLogs(
filters?: EstLogTransferFilterDto
filters?: EstLogTransferFilterDto,
): Promise<EstLogTransferResponseDto[] | null> {
const key = `orders:transfer-logs:${this.hashObject(filters || {})}`;
@@ -334,11 +297,6 @@ export class OrdersService {
);
}
/**
* Utilitário para gerar hash MD5 de objetos para chaves de cache
* @param obj - Objeto a ser serializado e hasheado
* @returns Hash MD5 do objeto serializado
*/
private hashObject(obj: unknown): string {
const objRecord = obj as Record<string, unknown>;
const sortedKeys = Object.keys(objRecord).sort();
@@ -346,21 +304,19 @@ export class OrdersService {
return createHash('md5').update(str).digest('hex');
}
async createInvoiceCheck(invoice: InvoiceCheckDto): Promise<{ message: string }> {
// Não usa cache para operações de escrita
async createInvoiceCheck(
invoice: InvoiceCheckDto,
): Promise<{ message: string }> {
return this.ordersRepository.createInvoiceCheck(invoice);
}
/**
* Buscar transportadoras do pedido com cache
*/
async getOrderCarriers(orderId: number): Promise<CarrierDto[]> {
const key = `orders:carriers:${orderId}`;
return getOrSetCache(this.redisClient, key, this.TTL_CARRIERS, async () => {
const carriers = await this.ordersRepository.getOrderCarriers(orderId);
return carriers.map(carrier => ({
return carriers.map((carrier) => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
@@ -368,9 +324,6 @@ export class OrdersService {
});
}
/**
* Buscar marca por ID com cache
*/
async findOrderByMark(orderId: number): Promise<MarkData> {
const key = `orders:mark:${orderId}`;
@@ -383,9 +336,6 @@ export class OrdersService {
});
}
/**
* Buscar todas as marcas disponíveis com cache
*/
async getAllMarks(): Promise<MarkData[]> {
const key = 'orders:marks:all';
@@ -394,9 +344,6 @@ export class OrdersService {
});
}
/**
* Buscar marcas por nome com cache
*/
async getMarksByName(markName: string): Promise<MarkData[]> {
const key = `orders:marks:name:${markName}`;
@@ -405,10 +352,9 @@ export class OrdersService {
});
}
/**
* Buscar entregas realizadas com cache baseado nos filtros
*/
async getCompletedDeliveries(query: DeliveryCompletedQuery): Promise<DeliveryCompleted[]> {
async getCompletedDeliveries(
query: DeliveryCompletedQuery,
): Promise<DeliveryCompleted[]> {
const key = `orders:completed-deliveries:${this.hashObject(query)}`;
return getOrSetCache(

View File

@@ -18,7 +18,8 @@ export class DebController {
@Get('find-by-cpf')
@ApiOperation({
summary: 'Busca débitos por CPF/CGCENT',
description: 'Busca débitos de um cliente usando CPF ou CGCENT. Opcionalmente pode filtrar por matrícula do funcionário ou código de cobrança.',
description:
'Busca débitos de um cliente usando CPF ou CGCENT. Opcionalmente pode filtrar por matrícula do funcionário ou código de cobrança.',
})
@ApiResponse({
status: 200,
@@ -34,9 +35,7 @@ export class DebController {
description: 'Erro interno do servidor',
})
@UsePipes(new ValidationPipe({ transform: true }))
async findByCpfCgcent(
@Query() query: FindDebDto,
): Promise<DebDto[]> {
async findByCpfCgcent(@Query() query: FindDebDto): Promise<DebDto[]> {
return await this.debService.findByCpfCgcent(
query.cpfCgcent,
query.matricula,

View File

@@ -7,21 +7,26 @@ import {
Query,
UsePipes,
UseGuards,
UseInterceptors,
ValidationPipe,
HttpException,
HttpStatus,
DefaultValuePipe,
ParseBoolPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags, ApiQuery, ApiParam, ApiResponse } from '@nestjs/swagger';
import { ResponseInterceptor } from '../../common/response.interceptor';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
ApiQuery,
ApiParam,
ApiResponse,
} from '@nestjs/swagger';
import { OrdersService } from '../application/orders.service';
import { FindOrdersDto } from '../dto/find-orders.dto';
import { FindOrdersByDeliveryDateDto } from '../dto/find-orders-by-delivery-date.dto';
import { JwtAuthGuard, } from 'src/auth/guards/jwt-auth.guard';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { InvoiceDto } from '../dto/find-invoice.dto';
import { OrderItemDto } from "../dto/OrderItemDto";
import { OrderItemDto } from '../dto/OrderItemDto';
import { LeadtimeDto } from '../dto/leadtime.dto';
import { CutItemDto } from '../dto/CutItemDto';
import { OrderDeliveryDto } from '../dto/OrderDeliveryDto';
@@ -34,7 +39,6 @@ import { OrderResponseDto } from '../dto/order-response.dto';
import { MarkResponseDto } from '../dto/mark-response.dto';
import { EstLogTransferResponseDto } from '../dto/estlogtransfer.dto';
@ApiTags('Orders')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@@ -45,15 +49,47 @@ export class OrdersController {
@Get('find')
@ApiOperation({
summary: 'Busca pedidos',
description: 'Busca pedidos com filtros avançados. Suporta filtros por data, cliente, vendedor, status, tipo de entrega e status de transferência.'
description:
'Busca pedidos com filtros avançados. Suporta filtros por data, cliente, vendedor, status, tipo de entrega e status de transferência.',
})
@ApiQuery({
name: 'includeCheckout',
required: false,
type: 'boolean',
description: 'Incluir dados de checkout',
})
@ApiQuery({
name: 'statusTransfer',
required: false,
type: 'string',
description:
'Filtrar por status de transferência (Em Trânsito, Em Separação, Aguardando Separação, Concluída)',
})
@ApiQuery({
name: 'markId',
required: false,
type: 'number',
description: 'ID da marca para filtrar pedidos',
})
@ApiQuery({
name: 'markName',
required: false,
type: 'string',
description: 'Nome da marca para filtrar pedidos (busca parcial)',
})
@ApiQuery({
name: 'hasPreBox',
required: false,
type: 'boolean',
description:
'Filtrar pedidos que tenham registros na tabela de transfer log',
})
@ApiQuery({ name: 'includeCheckout', required: false, type: 'boolean', description: 'Incluir dados de checkout' })
@ApiQuery({ name: 'statusTransfer', required: false, type: 'string', description: 'Filtrar por status de transferência (Em Trânsito, Em Separação, Aguardando Separação, Concluída)' })
@ApiQuery({ name: 'markId', required: false, type: 'number', description: 'ID da marca para filtrar pedidos' })
@ApiQuery({ name: 'markName', required: false, type: 'string', description: 'Nome da marca para filtrar pedidos (busca parcial)' })
@ApiQuery({ name: 'hasPreBox', required: false, type: 'boolean', description: 'Filtrar pedidos que tenham registros na tabela de transfer log' })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Lista de pedidos retornada com sucesso', type: [OrderResponseDto] })
@ApiResponse({
status: 200,
description: 'Lista de pedidos retornada com sucesso',
type: [OrderResponseDto],
})
findOrders(
@Query() query: FindOrdersDto,
@Query('includeCheckout', new DefaultValuePipe(false), ParseBoolPipe)
@@ -68,17 +104,42 @@ export class OrdersController {
@Get('find-by-delivery-date')
@ApiOperation({
summary: 'Busca pedidos por data de entrega',
description: 'Busca pedidos filtrados por data de entrega. Suporta filtros adicionais como status de transferência, cliente, vendedor, etc.'
description:
'Busca pedidos filtrados por data de entrega. Suporta filtros adicionais como status de transferência, cliente, vendedor, etc.',
})
@ApiQuery({
name: 'statusTransfer',
required: false,
type: 'string',
description:
'Filtrar por status de transferência (Em Trânsito, Em Separação, Aguardando Separação, Concluída)',
})
@ApiQuery({
name: 'markId',
required: false,
type: 'number',
description: 'ID da marca para filtrar pedidos',
})
@ApiQuery({
name: 'markName',
required: false,
type: 'string',
description: 'Nome da marca para filtrar pedidos (busca parcial)',
})
@ApiQuery({
name: 'hasPreBox',
required: false,
type: 'boolean',
description:
'Filtrar pedidos que tenham registros na tabela de transfer log',
})
@ApiQuery({ name: 'statusTransfer', required: false, type: 'string', description: 'Filtrar por status de transferência (Em Trânsito, Em Separação, Aguardando Separação, Concluída)' })
@ApiQuery({ name: 'markId', required: false, type: 'number', description: 'ID da marca para filtrar pedidos' })
@ApiQuery({ name: 'markName', required: false, type: 'string', description: 'Nome da marca para filtrar pedidos (busca parcial)' })
@ApiQuery({ name: 'hasPreBox', required: false, type: 'boolean', description: 'Filtrar pedidos que tenham registros na tabela de transfer log' })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Lista de pedidos por data de entrega retornada com sucesso', type: [OrderResponseDto] })
findOrdersByDeliveryDate(
@Query() query: FindOrdersByDeliveryDateDto,
) {
@ApiResponse({
status: 200,
description: 'Lista de pedidos por data de entrega retornada com sucesso',
type: [OrderResponseDto],
})
findOrdersByDeliveryDate(@Query() query: FindOrdersByDeliveryDateDto) {
return this.ordersService.findOrdersByDeliveryDate(query);
}
@@ -86,20 +147,16 @@ export class OrdersController {
@ApiOperation({ summary: 'Busca fechamento de caixa para um pedido' })
@ApiParam({ name: 'orderId' })
@UsePipes(new ValidationPipe({ transform: true }))
getOrderCheckout(
@Param('orderId', ParseIntPipe) orderId: number,
) {
getOrderCheckout(@Param('orderId', ParseIntPipe) orderId: number) {
return this.ordersService.getOrderCheckout(orderId);
}
@Get('invoice/:chavenfe')
@ApiParam({
name: 'chavenfe',
required: true,
description: 'Chave da Nota Fiscal (44 dígitos)',
})
@ApiOperation({ summary: 'Busca NF pela chave' })
@UsePipes(new ValidationPipe({ transform: true }))
async getInvoice(@Param('chavenfe') chavenfe: string): Promise<InvoiceDto> {
@@ -117,7 +174,9 @@ export class OrdersController {
@ApiOperation({ summary: 'Busca PELO numero do pedido' })
@ApiParam({ name: 'orderId' })
@UsePipes(new ValidationPipe({ transform: true }))
async getItens(@Param('orderId', ParseIntPipe) orderId: number): Promise<OrderItemDto[]> {
async getItens(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<OrderItemDto[]> {
try {
return await this.ordersService.getItens(orderId.toString());
} catch (error) {
@@ -131,7 +190,9 @@ export class OrdersController {
@ApiOperation({ summary: 'Busca itens cortados do pedido' })
@ApiParam({ name: 'orderId' })
@UsePipes(new ValidationPipe({ transform: true }))
async getCutItens(@Param('orderId', ParseIntPipe) orderId: number): Promise<CutItemDto[]> {
async getCutItens(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<CutItemDto[]> {
try {
return await this.ordersService.getCutItens(orderId.toString());
} catch (error) {
@@ -146,7 +207,9 @@ export class OrdersController {
@ApiOperation({ summary: 'Busca dados de entrega do pedido' })
@ApiParam({ name: 'orderId' })
@UsePipes(new ValidationPipe({ transform: true }))
async getOrderDelivery(@Param('orderId', ParseIntPipe) orderId: number): Promise<OrderDeliveryDto | null> {
async getOrderDelivery(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<OrderDeliveryDto | null> {
try {
return await this.ordersService.getOrderDelivery(orderId.toString());
} catch (error) {
@@ -161,7 +224,9 @@ export class OrdersController {
@ApiOperation({ summary: 'Consulta pedidos de transferência' })
@ApiParam({ name: 'orderId' })
@UsePipes(new ValidationPipe({ transform: true }))
async getTransfer(@Param('orderId', ParseIntPipe) orderId: number): Promise<OrderTransferDto[] | null> {
async getTransfer(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<OrderTransferDto[] | null> {
try {
return await this.ordersService.getTransfer(orderId);
} catch (error) {
@@ -176,7 +241,9 @@ export class OrdersController {
@ApiOperation({ summary: 'Consulta status do pedido' })
@ApiParam({ name: 'orderId' })
@UsePipes(new ValidationPipe({ transform: true }))
async getStatusOrder(@Param('orderId', ParseIntPipe) orderId: number): Promise<OrderStatusDto[] | null> {
async getStatusOrder(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<OrderStatusDto[] | null> {
try {
return await this.ordersService.getStatusOrder(orderId);
} catch (error) {
@@ -187,20 +254,32 @@ export class OrdersController {
}
}
@Get(':orderId/deliveries')
@ApiOperation({ summary: 'Consulta entregas do pedido' })
@ApiParam({ name: 'orderId' })
@ApiQuery({ name: 'createDateIni', required: false, description: 'Data inicial para filtro (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'createDateEnd', required: false, description: 'Data final para filtro (formato YYYY-MM-DD)' })
@ApiQuery({
name: 'createDateIni',
required: false,
description: 'Data inicial para filtro (formato YYYY-MM-DD)',
})
@ApiQuery({
name: 'createDateEnd',
required: false,
description: 'Data final para filtro (formato YYYY-MM-DD)',
})
async getOrderDeliveries(
@Param('orderId', ParseIntPipe) orderId: number,
@Query('createDateIni') createDateIni?: string,
@Query('createDateEnd') createDateEnd?: string,
): Promise<OrderDeliveryDto[]> {
// Definir datas padrão caso não sejam fornecidas
const defaultDateIni = createDateIni || new Date(new Date().setDate(new Date().getDate() - 30)).toISOString().split('T')[0];
const defaultDateEnd = createDateEnd || new Date().toISOString().split('T')[0];
const defaultDateIni =
createDateIni ||
new Date(new Date().setDate(new Date().getDate() - 30))
.toISOString()
.split('T')[0];
const defaultDateEnd =
createDateEnd || new Date().toISOString().split('T')[0];
return this.ordersService.getOrderDeliveries(orderId.toString(), {
createDateIni: defaultDateIni,
@@ -208,12 +287,13 @@ export class OrdersController {
});
}
@Get('leadtime/:orderId')
@ApiOperation({ summary: 'Consulta leadtime do pedido' })
@ApiParam({ name: 'orderId' })
@UsePipes(new ValidationPipe({ transform: true }))
async getLeadtime(@Param('orderId', ParseIntPipe) orderId: number): Promise<LeadtimeDto[]> {
async getLeadtime(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<LeadtimeDto[]> {
try {
return await this.ordersService.getLeadtime(orderId.toString());
} catch (error) {
@@ -227,13 +307,15 @@ export class OrdersController {
@Post('invoice/check')
@ApiOperation({ summary: 'Cria conferência de nota fiscal' })
@UsePipes(new ValidationPipe({ transform: true }))
async createInvoiceCheck(@Body() invoice: InvoiceCheckDto): Promise<{ message: string }> {
async createInvoiceCheck(
@Body() invoice: InvoiceCheckDto,
): Promise<{ message: string }> {
try {
return await this.ordersService.createInvoiceCheck(invoice);
} catch (error) {
throw new HttpException(
error.message || 'Erro ao salvar conferência',
error.status || HttpStatus.INTERNAL_SERVER_ERROR
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@@ -242,7 +324,9 @@ async createInvoiceCheck(@Body() invoice: InvoiceCheckDto): Promise<{ message: s
@ApiOperation({ summary: 'Busca transportadoras do pedido' })
@ApiParam({ name: 'orderId', example: 236001388 })
@UsePipes(new ValidationPipe({ transform: true }))
async getOrderCarriers(@Param('orderId', ParseIntPipe) orderId: number): Promise<CarrierDto[]> {
async getOrderCarriers(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<CarrierDto[]> {
try {
return await this.ordersService.getOrderCarriers(orderId);
} catch (error) {
@@ -257,9 +341,15 @@ async getOrderCarriers(@Param('orderId', ParseIntPipe) orderId: number): Promise
@ApiOperation({ summary: 'Busca marca por ID do pedido' })
@ApiParam({ name: 'orderId', example: 236001388 })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Marca encontrada com sucesso', type: MarkResponseDto })
@ApiResponse({
status: 200,
description: 'Marca encontrada com sucesso',
type: MarkResponseDto,
})
@ApiResponse({ status: 404, description: 'Marca não encontrada' })
async findOrderByMark(@Param('orderId', ParseIntPipe) orderId: number): Promise<MarkResponseDto> {
async findOrderByMark(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<MarkResponseDto> {
try {
return await this.ordersService.findOrderByMark(orderId);
} catch (error) {
@@ -273,7 +363,11 @@ async findOrderByMark(@Param('orderId', ParseIntPipe) orderId: number): Promise<
@Get('marks')
@ApiOperation({ summary: 'Busca todas as marcas disponíveis' })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Lista de marcas retornada com sucesso', type: [MarkResponseDto] })
@ApiResponse({
status: 200,
description: 'Lista de marcas retornada com sucesso',
type: [MarkResponseDto],
})
async getAllMarks(): Promise<MarkResponseDto[]> {
try {
return await this.ordersService.getAllMarks();
@@ -287,10 +381,21 @@ async getAllMarks(): Promise<MarkResponseDto[]> {
@Get('marks/search')
@ApiOperation({ summary: 'Busca marcas por nome' })
@ApiQuery({ name: 'name', required: true, type: 'string', description: 'Nome da marca para buscar' })
@ApiQuery({
name: 'name',
required: true,
type: 'string',
description: 'Nome da marca para buscar',
})
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Lista de marcas encontradas', type: [MarkResponseDto] })
async getMarksByName(@Query('name') markName: string): Promise<MarkResponseDto[]> {
@ApiResponse({
status: 200,
description: 'Lista de marcas encontradas',
type: [MarkResponseDto],
})
async getMarksByName(
@Query('name') markName: string,
): Promise<MarkResponseDto[]> {
try {
return await this.ordersService.getMarksByName(markName);
} catch (error) {
@@ -303,16 +408,51 @@ async getMarksByName(@Query('name') markName: string): Promise<MarkResponseDto[]
@Get('transfer-log/:orderId')
@ApiOperation({ summary: 'Busca log de transferência por ID do pedido' })
@ApiParam({ name: 'orderId', description: 'ID do pedido para buscar log de transferência' })
@ApiQuery({ name: 'dttransf', required: false, type: 'string', description: 'Data de transferência (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'codfilial', required: false, type: 'number', description: 'digo da filial de origem' })
@ApiQuery({ name: 'codfilialdest', required: false, type: 'number', description: 'Código da filial de destino' })
@ApiQuery({ name: 'numpedloja', required: false, type: 'number', description: 'Número do pedido da loja' })
@ApiQuery({ name: 'numpedtransf', required: false, type: 'number', description: 'Número do pedido de transferência' })
@ApiParam({
name: 'orderId',
description: 'ID do pedido para buscar log de transferência',
})
@ApiQuery({
name: 'dttransf',
required: false,
type: 'string',
description: 'Data de transferência (formato YYYY-MM-DD)',
})
@ApiQuery({
name: 'codfilial',
required: false,
type: 'number',
description: 'Código da filial de origem',
})
@ApiQuery({
name: 'codfilialdest',
required: false,
type: 'number',
description: 'Código da filial de destino',
})
@ApiQuery({
name: 'numpedloja',
required: false,
type: 'number',
description: 'Número do pedido da loja',
})
@ApiQuery({
name: 'numpedtransf',
required: false,
type: 'number',
description: 'Número do pedido de transferência',
})
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Log de transferência encontrado com sucesso', type: [EstLogTransferResponseDto] })
@ApiResponse({
status: 200,
description: 'Log de transferência encontrado com sucesso',
type: [EstLogTransferResponseDto],
})
@ApiResponse({ status: 400, description: 'OrderId inválido' })
@ApiResponse({ status: 404, description: 'Log de transferência não encontrado' })
@ApiResponse({
status: 404,
description: 'Log de transferência não encontrado',
})
async getTransferLog(
@Param('orderId', ParseIntPipe) orderId: number,
@Query('dttransf') dttransf?: string,
@@ -341,15 +481,54 @@ async getTransferLog(
@Get('transfer-log')
@ApiOperation({ summary: 'Busca logs de transferência com filtros' })
@ApiQuery({ name: 'dttransf', required: false, type: 'string', description: 'Data de transferência (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'dttransfIni', required: false, type: 'string', description: 'Data de transferência inicial (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'dttransfEnd', required: false, type: 'string', description: 'Data de transferência final (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'codfilial', required: false, type: 'number', description: 'Código da filial de origem' })
@ApiQuery({ name: 'codfilialdest', required: false, type: 'number', description: 'Código da filial de destino' })
@ApiQuery({ name: 'numpedloja', required: false, type: 'number', description: 'Número do pedido da loja' })
@ApiQuery({ name: 'numpedtransf', required: false, type: 'number', description: 'Número do pedido de transferência' })
@ApiQuery({
name: 'dttransf',
required: false,
type: 'string',
description: 'Data de transferência (formato YYYY-MM-DD)',
})
@ApiQuery({
name: 'dttransfIni',
required: false,
type: 'string',
description: 'Data de transferência inicial (formato YYYY-MM-DD)',
})
@ApiQuery({
name: 'dttransfEnd',
required: false,
type: 'string',
description: 'Data de transferência final (formato YYYY-MM-DD)',
})
@ApiQuery({
name: 'codfilial',
required: false,
type: 'number',
description: 'Código da filial de origem',
})
@ApiQuery({
name: 'codfilialdest',
required: false,
type: 'number',
description: 'Código da filial de destino',
})
@ApiQuery({
name: 'numpedloja',
required: false,
type: 'number',
description: 'Número do pedido da loja',
})
@ApiQuery({
name: 'numpedtransf',
required: false,
type: 'number',
description: 'Número do pedido de transferência',
})
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Logs de transferência encontrados com sucesso', type: [EstLogTransferResponseDto] })
@ApiResponse({
status: 200,
description: 'Logs de transferência encontrados com sucesso',
type: [EstLogTransferResponseDto],
})
@ApiResponse({ status: 400, description: 'Filtros inválidos' })
async getTransferLogs(
@Query('dttransf') dttransf?: string,

View File

@@ -7,4 +7,3 @@ export class CutItemDto {
cutQuantity: number;
separedQuantity: number;
}

View File

@@ -70,7 +70,7 @@ export class DebDto {
@ApiProperty({
description: 'Valor da prestação',
example: 150.50,
example: 150.5,
})
valor: number;
@@ -81,4 +81,3 @@ export class DebDto {
})
situacao: string;
}

View File

@@ -28,4 +28,3 @@ export class OrderDeliveryDto {
releaseDate: Date;
completedDeliveries?: DeliveryCompleted[];
}

Some files were not shown because too many files have changed in this diff Show More