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);
});
@@ -183,7 +189,7 @@ describe('AuthService - createToken', () => {
* de validação no método createToken. Atualmente, o método não valida
* os parâmetros de entrada, o que pode causar problemas de segurança
* e tokens inválidos.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não valida se IDs são positivos
* 2. Não valida se strings estão vazias
@@ -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

@@ -14,7 +14,7 @@ describe('AuthService - createTokenPair', () => {
describe('createTokenPair - Tests that expose problems', () => {
/**
* NOTA: Estes testes identificam problemas no método createTokenPair.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não há rollback se um token é criado mas o outro falha
* 2. Não valida se os tokens foram realmente gerados
@@ -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');
});
@@ -101,18 +141,26 @@ describe('AuthService - createTokenPair', () => {
* Solução esperada: Access token sempre primeiro.
*/
const callOrder = [];
context.mockJwtService.sign.mockImplementation(() => {
callOrder.push('accessToken');
return 'mock.access.token';
});
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(async () => {
callOrder.push('refreshToken');
return 'mock.refresh.token';
});
await context.service.createTokenPair(1, 100, 'test', 'test@test.com', 'STORE001');
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(
async () => {
callOrder.push('refreshToken');
return 'mock.refresh.token';
},
);
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');
@@ -135,7 +189,7 @@ describe('AuthService - createTokenPair', () => {
* Cenário: Múltiplas chamadas simultâneas para o mesmo usuário.
* Problema: Pode criar múltiplos pares de tokens inconsistentes.
* Solução esperada: JWT service gera tokens únicos com timestamps diferentes.
*
*
* Nota: Na implementação real, o JWT service inclui timestamp e outros dados
* que garantem unicidade. Aqui simulamos isso no mock.
*/
@@ -145,19 +199,42 @@ describe('AuthService - createTokenPair', () => {
return `mock.access.token.${callCount}`;
});
context.mockRefreshTokenService.generateRefreshToken.mockImplementation(async () => {
return `mock.refresh.token.${Math.random()}`;
});
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(() => {
@@ -24,7 +28,7 @@ describe('AuthService - logout', () => {
describe('logout - Tests that expose problems', () => {
/**
* NOTA: Estes testes identificam problemas no método logout.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não valida token de entrada (vazio, null, undefined)
* 2. Não valida se token foi decodificado corretamente
@@ -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(() => {
@@ -29,7 +31,7 @@ describe('AuthService - refreshAccessToken', () => {
describe('refreshAccessToken - Tests that expose problems', () => {
/**
* NOTA: Estes testes identificam problemas no método refreshAccessToken.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não valida refresh token antes de processar
* 2. Não valida dados retornados pelo refresh token service
@@ -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
import {
LoginAuditFiltersDto,
LoginAuditResponseDto,
LoginStatsDto,
LoginStatsFiltersDto,
} from './dto/login-audit.dto';
import {
ApiTags,
@@ -66,9 +69,12 @@ 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);
const result = await this.commandBus.execute(command);
@@ -76,7 +82,7 @@ export class AuthController {
if (!result.success) {
await this.rateLimitingService.recordAttempt(ip, false);
await this.loginAuditService.logLoginAttempt({
username: dto.username,
ipAddress: ip,
@@ -84,7 +90,7 @@ export class AuthController {
success: false,
failureReason: result.error,
});
throw new HttpException(
new ResultModel(false, result.error, null, result.error),
HttpStatus.UNAUTHORIZED,
@@ -94,19 +100,23 @@ export class AuthController {
await this.rateLimitingService.recordAttempt(ip, true);
const user = result.data;
/**
* 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(
user.id,
ip,
@@ -161,7 +171,6 @@ export class AuthController {
);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@@ -170,10 +179,15 @@ export class AuthController {
@ApiUnauthorizedResponse({ description: 'Token inválido ou expirado' })
async logout(@Request() req): Promise<{ message: string }> {
const token = req.headers.authorization?.replace('Bearer ', '');
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,
@@ -238,7 +261,7 @@ export class AuthController {
): Promise<{ message: string }> {
const userId = req.user.id;
await this.sessionManagementService.terminateSession(userId, sessionId);
return {
message: 'Sessão encerrada com sucesso',
};
@@ -253,7 +276,7 @@ export class AuthController {
async terminateAllSessions(@Request() req): Promise<{ message: string }> {
const userId = req.user.id;
await this.sessionManagementService.terminateAllSessions(userId);
return {
message: 'Todas as sessões foram encerradas com sucesso',
};
@@ -273,7 +296,7 @@ export class AuthController {
@Request() req,
): Promise<LoginAuditResponseDto> {
const userId = req.user.id;
const auditFilters = {
...filters,
userId: filters.userId || userId,
@@ -282,9 +305,9 @@ 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),
})),
@@ -309,12 +332,12 @@ export class AuthController {
): Promise<LoginStatsDto> {
const userId = req.user.id;
const days = filters.days || 7;
const stats = await this.loginAuditService.getLoginStats(
filters.userId || userId,
days,
);
return stats;
}
@@ -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,
);
}
@@ -362,15 +389,17 @@ export class AuthController {
* Busca o usuário pelo username para obter o ID
*/
const user = await this.authService.findUserByUsername(username);
if (!user) {
return {
hasActiveSession: false,
};
}
const activeSession = await this.sessionManagementService.hasActiveSession(user.id);
const activeSession = await this.sessionManagementService.hasActiveSession(
user.id,
);
if (!activeSession) {
return {
hasActiveSession: false,
@@ -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

@@ -35,14 +35,14 @@ import { LoginAuditService } from '../services/login-audit.service';
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
TokenBlacklistService,
RateLimitingService,
AuthService,
JwtStrategy,
TokenBlacklistService,
RateLimitingService,
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()) {
@@ -77,7 +99,7 @@ export class AuthService {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const multipleAtSymbols = (email.match(/@/g) || []).length > 1;
if (!emailRegex.test(email) || multipleAtSymbols) {
throw new BadRequestException('Formato de email inválido');
}
@@ -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);
if (!accessToken || typeof accessToken !== 'string' || !accessToken.trim()) {
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()
) {
throw new Error('Token de acesso inválido gerado');
}
const refreshToken = await this.refreshTokenService.generateRefreshToken(id, sessionId);
if (!refreshToken || typeof refreshToken !== 'string' || !refreshToken.trim()) {
const refreshToken = await this.refreshTokenService.generateRefreshToken(
id,
sessionId,
);
if (
!refreshToken ||
typeof refreshToken !== 'string' ||
!refreshToken.trim()
) {
throw new Error('Refresh token inválido gerado');
}
@@ -121,8 +168,10 @@ 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,10 +184,11 @@ export class AuthService {
this.validateUserDataForToken(user);
if (tokenData.sessionId) {
const isSessionActive = await this.sessionManagementService.isSessionActive(
user.id,
tokenData.sessionId
);
const isSessionActive =
await this.sessionManagementService.isSessionActive(
user.id,
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}`,
);
}
}
}
@@ -289,4 +374,4 @@ export class AuthService {
async findUserByUsername(username: string) {
return this.userRepository.findByUsername(username);
}
}
}

View File

@@ -1,7 +1,6 @@
export class AuthenticateUserCommand {
constructor(
public readonly username: string,
public readonly password: string,
) {}
}
constructor(
public readonly username: string,
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');
@@ -30,7 +35,6 @@ export class AuthenticateUserHandler implements ICommandHandler<AuthenticateUser
if (user.situacao === 'B') {
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' })
@@ -114,22 +137,30 @@ export class LoginStatsDto {
topIps: Array<{ ip: string; count: number }>;
@ApiProperty({ description: 'Estatísticas diárias' })
dailyStats: Array<{
date: string;
attempts: number;
successes: number;
failures: number;
dailyStats: Array<{
date: string;
attempts: number;
successes: number;
failures: number;
}>;
}
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

@@ -50,7 +50,7 @@ describe('RateLimitingGuard - Tests that expose problems', () => {
describe('canActivate', () => {
/**
* NOTA: Estes testes identificam problemas no método canActivate.
*
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não valida se IP extraído é válido
* 2. Não valida se rate limiting service retorna dados válidos
@@ -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,
@@ -30,13 +37,14 @@ export class RateLimitingGuard implements CanActivate {
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (!isAllowed) {
let attemptInfo;
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,
@@ -49,11 +57,12 @@ export class RateLimitingGuard implements CanActivate {
}
this.validateAttemptInfo(attemptInfo);
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 '';
@@ -90,7 +102,7 @@ export class RateLimitingGuard implements CanActivate {
}
const trimmedIp = rawIp.trim();
if (trimmedIp === '') {
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;
});
@@ -180,17 +196,17 @@ export class RateLimitingGuard implements CanActivate {
if (ip.includes('::')) {
const parts = ip.split('::');
if (parts.length > 2) return false;
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,11 +1,11 @@
/* eslint-disable prettier/prettier */
export interface JwtPayload {
id: number;
sellerId: number;
storeId: string;
username: string;
email: string;
exp?: number; // Timestamp de expiração do JWT
sessionId?: string; // ID da sessão atual
}
/* eslint-disable prettier/prettier */
export interface JwtPayload {
id: number;
sellerId: number | null;
storeId: string;
username: string;
email: string;
exp?: number; // Timestamp de expiração do JWT
sessionId?: string; // ID da sessão atual
}

View File

@@ -1,16 +1,15 @@
export class Result<T> {
private constructor(
public readonly success: boolean,
public readonly data?: T,
public readonly error?: string,
) {}
static ok<U>(data: U): Result<U> {
return new Result<U>(true, data);
}
static fail<U>(message: string): Result<U> {
return new Result<U>(false, undefined, message);
}
private constructor(
public readonly success: boolean,
public readonly data?: T,
public readonly error?: string,
) {}
static ok<U>(data: U): Result<U> {
return new Result<U>(true, data);
}
static fail<U>(message: string): Result<U> {
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,14 +31,14 @@ 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();
const auditLog: LoginAuditLog = {
...log,
id: logId,
@@ -69,24 +69,26 @@ 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[] = [];
for (const logId of logIds) {
const logKey = this.buildLogKey(logId);
const logData = await this.redis.get(logKey);
if (!logData) {
continue;
}
const log: LoginAuditLog = JSON.parse(logData as string);
if (typeof log.timestamp === 'string') {
log.timestamp = new Date(log.timestamp);
}
if (!this.matchesFilters(log, filters)) {
continue;
}
@@ -98,21 +100,29 @@ export class LoginAuditService {
const offset = filters.offset || 0;
const limit = filters.limit || 100;
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);
const filters: LoginAuditFilters = {
startDate,
endDate,
@@ -124,38 +134,50 @@ export class LoginAuditService {
}
const logs = await this.getLoginLogs(filters);
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);
});
stats.topIps = Array.from(ipCounts.entries())
.map(([ip, count]) => ({ ip, count }))
.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) {
dayStats.successes++;
dailyCounts.set(date, dayStats);
return;
}
dayStats.failures++;
dailyCounts.set(date, dayStats);
});
@@ -168,9 +190,11 @@ 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);
for (const date of oldDates) {
const dateLogsKey = this.buildDateLogsKey(date);
@@ -190,18 +214,20 @@ 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);
const logIds: string[] = [];
for (const date of dates) {
const dateLogsKey = this.buildDateLogsKey(date);
const dateLogIds = await this.redis.lrange(dateLogsKey, 0, -1);
logIds.push(...dateLogIds);
}
return logIds;
}
@@ -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,11 +261,17 @@ 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;
}
if (filters.success !== undefined && log.success !== filters.success) {
return false;
}
@@ -256,12 +290,12 @@ export class LoginAuditService {
private getDateRange(startDate: Date, endDate: Date): string[] {
const dates: string[] = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
dates.push(DateUtil.toBrazilString(currentDate, 'yyyy-MM-dd'));
currentDate.setDate(currentDate.getDate() + 1);
}
return dates;
}
}

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);
@@ -98,7 +103,7 @@ export class RateLimitingService {
async clearAttempts(ip: string): Promise<void> {
const key = this.buildAttemptKey(ip);
const blockKey = this.buildBlockKey(ip);
await this.redis.del(key);
await this.redis.del(blockKey);
}

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(),
};
@@ -50,7 +53,7 @@ export class RefreshTokenService {
async validateRefreshToken(refreshToken: string): Promise<JwtPayload> {
try {
const decoded = this.jwtService.verify(refreshToken) as any;
if (decoded.type !== 'refresh') {
throw new UnauthorizedException('Token inválido');
}
@@ -68,14 +71,14 @@ export class RefreshTokenService {
throw new UnauthorizedException('Refresh token expirado');
}
return {
id: userId,
sellerId: 0,
storeId: '',
username: '',
return {
id: userId,
sellerId: 0,
storeId: '',
username: '',
email: '',
sessionId: sessionId || tokenData.sessionId,
tokenId
tokenId,
} as JwtPayload;
} catch (error) {
throw new UnauthorizedException('Refresh token inválido');
@@ -90,7 +93,7 @@ export class RefreshTokenService {
async revokeAllRefreshTokens(userId: number): Promise<void> {
const pattern = this.buildRefreshTokenPattern(userId);
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
@@ -99,9 +102,9 @@ export class RefreshTokenService {
async getActiveRefreshTokens(userId: number): Promise<RefreshTokenData[]> {
const pattern = this.buildRefreshTokenPattern(userId);
const keys = await this.redis.keys(pattern);
const tokens: RefreshTokenData[] = [];
for (const key of keys) {
const tokenData = await this.redis.get<RefreshTokenData>(key);
if (tokenData && tokenData.expiresAt > DateUtil.nowTimestamp()) {
@@ -114,11 +117,11 @@ export class RefreshTokenService {
private async limitRefreshTokensPerUser(userId: number): Promise<void> {
const activeTokens = await this.getActiveRefreshTokens(userId);
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,12 +60,15 @@ 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);
const sessions: SessionData[] = [];
for (const key of keys) {
const sessionData = await this.redis.get<SessionData>(key);
if (sessionData && sessionData.isActive) {
@@ -89,7 +97,7 @@ export class SessionManagementService {
async terminateAllSessions(userId: number): Promise<void> {
const pattern = this.buildSessionPattern(userId);
const keys = await this.redis.keys(pattern);
for (const key of keys) {
const sessionData = await this.redis.get<SessionData>(key);
if (sessionData) {
@@ -99,10 +107,13 @@ 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);
for (const key of keys) {
const sessionData = await this.redis.get<SessionData>(key);
if (sessionData && sessionData.sessionId !== currentSessionId) {
@@ -115,7 +126,7 @@ export class SessionManagementService {
async isSessionActive(userId: number, sessionId: string): Promise<boolean> {
const key = this.buildSessionKey(userId, sessionId);
const sessionData = await this.redis.get<SessionData>(key);
return sessionData ? sessionData.isActive : false;
}
@@ -126,11 +137,11 @@ export class SessionManagementService {
private async limitSessionsPerUser(userId: number): Promise<void> {
const activeSessions = await this.getActiveSessions(userId);
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

@@ -20,7 +20,7 @@ export class TokenBlacklistService {
const blacklistKey = this.buildBlacklistKey(token);
const ttl = expiresIn || this.calculateTokenTTL(decoded);
await this.redis.set(blacklistKey, 'blacklisted', ttl);
} catch (error) {
throw new Error(`Erro ao adicionar token à blacklist: ${error.message}`);
@@ -45,7 +45,7 @@ export class TokenBlacklistService {
async clearUserBlacklist(userId: number): Promise<void> {
const pattern = `auth:blacklist:${userId}:*`;
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
@@ -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

@@ -1,14 +1,14 @@
/**
* Teste para JwtStrategy
*
*
* NOTA: Este teste foi escrito seguindo TDD (Test-Driven Development).
* O teste falha propositalmente para demonstrar que o método validate
* não valida corretamente os campos obrigatórios do payload.
*
*
* Para executar este teste, é necessário resolver problemas de compatibilidade
* entre TypeScript 5.8.3 e ts-jest 26.4.3. Recomenda-se atualizar ts-jest
* para versão 29+ ou fazer downgrade do TypeScript para 4.x.
*
*
* O código de produção já foi corrigido (linhas 32-34 do jwt-strategy.ts).
*/
@@ -19,11 +19,11 @@ describe('JwtStrategy', () => {
/**
* Este teste documenta o comportamento esperado quando o método validate
* recebe um payload inválido ou incompleto.
*
*
* ANTES DA CORREÇÃO:
* O método tentava acessar payload.id e payload.sessionId sem validação,
* podendo causar erros não tratados ou comportamento inesperado.
*
*
* DEPOIS DA CORREÇÃO (implementado em jwt-strategy.ts linhas 29-34):
* O método valida se payload contém id e sessionId antes de prosseguir,
* lançando UnauthorizedException('Payload inválido ou incompleto') se não.
@@ -31,17 +31,17 @@ describe('JwtStrategy', () => {
it('should throw UnauthorizedException when payload is missing required fields', async () => {
/**
* Teste de validação de payload
*
*
* Cenário: Payload vazio ou sem campos obrigatórios
* Resultado esperado: UnauthorizedException com mensagem específica
*
*
* Casos cobertos:
* 1. Payload completamente vazio: {}
* 2. Payload apenas com id: { id: 1 }
* 3. Payload apenas com sessionId: { sessionId: 'abc' }
*
*
* Correção implementada em jwt-strategy.ts:
*
*
* async validate(payload: JwtPayload, req: any) {
* if (!payload?.id || !payload?.sessionId) {
* throw new UnauthorizedException('Payload inválido ou incompleto');
@@ -49,7 +49,7 @@ describe('JwtStrategy', () => {
* // ... resto do código
* }
*/
const testCases = [
{ payload: {}, description: 'payload vazio' },
{ payload: { id: 1 }, description: 'payload sem sessionId' },

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,15 +39,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const cachedUser = await this.redis.get<any>(sessionKey);
if (cachedUser) {
const isSessionActive = await this.sessionManagementService.isSessionActive(
payload.id,
payload.sessionId
);
const isSessionActive =
await this.sessionManagementService.isSessionActive(
payload.id,
payload.sessionId,
);
if (!isSessionActive) {
throw new UnauthorizedException('Sessão expirada ou inválida');
}
return {
id: cachedUser.id,
sellerId: cachedUser.sellerId,
@@ -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

@@ -5,7 +5,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
@Injectable()
export class UserRepository {
constructor(
@InjectDataSource('oracle')
@InjectDataSource('oracle')
private readonly dataSource: DataSource,
) {}
@@ -40,18 +40,18 @@ export class UserRepository {
WHERE REGEXP_REPLACE(PCUSUARI.CPF, '[^0-9]', '') = REGEXP_REPLACE(:1, '[^0-9]', '')
AND PCUSUARI.EMAIL = :2
`;
const users = await this.dataSource.query(sql, [cpf, email]);
return users[0] || null;
}
async updatePassword(sellerId: number, newPasswordHash: string) {
const sql = `
UPDATE PCUSUARI SET SENHALOGIN = :1 WHERE CODUSUR = :2
`;
await this.dataSource.query(sql, [newPasswordHash, sellerId]);
}
async findByIdAndPassword(sellerId: number, passwordHash: string) {
const sql = `
SELECT CODUSUR as "sellerId", NOME as "name", EMAIL as "email"

View File

@@ -19,10 +19,13 @@ 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);
return { ...user, 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);
@@ -22,7 +23,7 @@ export class RateLimiterMiddleware implements NestMiddleware {
const key = this.generateKey(req);
const now = Date.now();
if (!this.store.has(key)) {
this.store.set(key, { count: 1, expiration: now + this.ttl * 1000 });
this.setRateLimitHeaders(res, 1);
@@ -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

@@ -7,20 +7,20 @@ export class RequestSanitizerMiddleware implements NestMiddleware {
if (req.headers) {
this.sanitizeObject(req.headers);
}
if (req.query) {
this.sanitizeObject(req.query);
}
if (req.body) {
this.sanitizeObject(req.body);
}
next();
}
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) {
@@ -32,17 +32,17 @@ export class RequestSanitizerMiddleware implements NestMiddleware {
private sanitizeString(str: string): string {
// Remover tags HTML básicas
str = str.replace(/<(|\/|[^>\/bi]|\/[^>bi]|[^\/>][^>]+|\/[^>][^>]+)>/g, '');
// Remover scripts JavaScript
str = str.replace(/javascript:/g, '');
str = str.replace(/on\w+=/g, '');
// Remover comentários HTML
str = str.replace(/<!--[\s\S]*?-->/g, '');
// Sanitizar caracteres especiais para evitar SQL injection
str = str.replace(/'/g, "''");
return str;
}
}
}

View File

@@ -1,21 +1,25 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ResultModel } from '../shared/ResultModel';
@Injectable()
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);
}),
);
}
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ResultModel } from '../shared/ResultModel';
@Injectable()
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);
}),
);
}
}

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,
@@ -11,24 +15,27 @@ export function IsSanitized(validationOptions?: ValidationOptions) {
validator: {
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;
}
return true;
},
defaultMessage(args: ValidationArguments) {
@@ -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,13 +56,16 @@ 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
return value > 0;
},
@@ -65,4 +75,4 @@ export function IsSecureId(validationOptions?: ValidationOptions) {
},
});
};
}
}

View File

@@ -1,10 +1,13 @@
export interface IRedisClient {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
del(key: string): Promise<void>;
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>;
}
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
del(key: string): Promise<void>;
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>;
}

View File

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

View File

@@ -6,13 +6,13 @@ 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> {
const data = await this.redis.get(key);
if (!data) return null;
try {
return JSON.parse(data);
} catch (error) {
@@ -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

@@ -9,4 +9,4 @@ import { RedisClientAdapterProvider } from './redis-client.adapter.provider';
providers: [RedisProvider, RedisClientAdapterProvider],
exports: [RedisProvider, RedisClientAdapterProvider],
})
export class RedisModule {}
export class RedisModule {}

View File

@@ -1,21 +1,21 @@
import { Provider } from '@nestjs/common';
import Redis from 'ioredis';
import { ConfigService } from '@nestjs/config';
import { Provider } from '@nestjs/common';
import Redis from 'ioredis';
import { ConfigService } from '@nestjs/config';
export const RedisProvider: Provider = {
provide: 'REDIS_CLIENT',
useFactory: (configService: ConfigService) => {
const redis = new Redis({
host: configService.get<string>('REDIS_HOST', '10.1.1.109'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD', '1234'),
});
export const RedisProvider: Provider = {
provide: 'REDIS_CLIENT',
useFactory: (configService: ConfigService) => {
const redis = new Redis({
host: configService.get<string>('REDIS_HOST', '10.1.1.109'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD', '1234'),
});
redis.on('error', (err) => {
console.error('Erro ao conectar ao Redis:', err);
});
redis.on('error', (err) => {
console.error('Erro ao conectar ao Redis:', err);
});
return redis;
},
inject: [ConfigService],
};
return redis;
},
inject: [ConfigService],
};

View File

@@ -1,16 +1,16 @@
import { registerAs } from '@nestjs/config';
export const databaseConfig = registerAs('database', () => ({
oracle: {
connectString: `(DESCRIPTION = (ADDRESS_LIST = (ADDRESS = (PROTOCOL = TCP)(HOST = ${process.env.ORACLE_HOST})(PORT = ${process.env.ORACLE_PORT})))(CONNECT_DATA = (SERVICE_NAME = ${process.env.ORACLE_SERVICE})))`,
username: process.env.ORACLE_USER,
password: process.env.ORACLE_PASSWORD,
},
postgres: {
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432', 10),
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
},
}));
import { registerAs } from '@nestjs/config';
export const databaseConfig = registerAs('database', () => ({
oracle: {
connectString: `(DESCRIPTION = (ADDRESS_LIST = (ADDRESS = (PROTOCOL = TCP)(HOST = ${process.env.ORACLE_HOST})(PORT = ${process.env.ORACLE_PORT})))(CONNECT_DATA = (SERVICE_NAME = ${process.env.ORACLE_SERVICE})))`,
username: process.env.ORACLE_USER,
password: process.env.ORACLE_PASSWORD,
},
postgres: {
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432', 10),
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
},
}));

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,17 +5,23 @@ 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);
const validPoolMax = Math.max(validPoolMin + 1, poolMax);
const validIdleTimeout = Math.max(1000, idleTimeout);
const validConnectionTimeout = Math.max(1000, connectionTimeout);
const validAcquireTimeout = Math.max(1000, acquireTimeout);
const options: DataSourceOptions = {
type: 'postgres',
host: config.get('POSTGRES_HOST'),
@@ -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 +1 @@
export const DATA_SOURCE = 'DATA_SOURCE';
export const DATA_SOURCE = 'DATA_SOURCE';

View File

@@ -20,4 +20,4 @@ import { createOracleConfig } from '../configs/typeorm.oracle.config';
],
exports: [DATA_SOURCE],
})
export class DatabaseModule {}
export class DatabaseModule {}

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,50 +1,53 @@
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> = {}) => ({
findStores: jest.fn(),
findSellers: jest.fn(),
findBillings: jest.fn(),
findCustomers: jest.fn(),
findAllProducts: jest.fn(),
findAllCarriers: jest.fn(),
findRegions: jest.fn(),
...methods,
} as any);
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 = () => ({
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
} as any);
export const createMockRedisClient = () =>
({
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
} as any);
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);
}
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,116 +1,184 @@
import { Controller, Get, Param, Query, UseGuards, UsePipes, ValidationPipe, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { DataConsultService } from './data-consult.service';
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';
import { BillingDto } from './dto/billing.dto';
import { CustomerDto } from './dto/customer.dto';
import { RegionDto } from './dto/region.dto';
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] })
async stores(): Promise<StoreDto[]> {
return this.dataConsultService.stores();
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('sellers')
@ApiOperation({ summary: 'Lista todos os vendedores' })
@ApiResponse({ status: 200, description: 'Lista de vendedores retornada com sucesso', type: [SellerDto] })
async sellers(): Promise<SellerDto[]> {
return this.dataConsultService.sellers();
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('billings')
@ApiOperation({ summary: 'Retorna informações de faturamento' })
@ApiResponse({ status: 200, description: 'Informações de faturamento retornadas com sucesso', type: [BillingDto] })
async billings(): Promise<BillingDto[]> {
return this.dataConsultService.billings();
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@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] })
async customer(@Param('filter') filter: string): Promise<CustomerDto[]> {
return this.dataConsultService.customers(filter);
}
@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] })
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] })
async getAllProducts(): Promise<ProductDto[]> {
return this.dataConsultService.getAllProducts();
}
@Get('carriers/all')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Lista todas as transportadoras cadastradas' })
@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()
@ApiOperation({ summary: 'Busca transportadoras por período de data' })
@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[]> {
return this.dataConsultService.getCarriersByDate(query);
}
@Get('carriers/order/:orderId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@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] })
@UsePipes(new ValidationPipe({ transform: true }))
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] })
async getRegions(): Promise<RegionDto[]> {
return this.dataConsultService.getRegions();
}
}
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 { ProductDto } from './dto/product.dto';
import { StoreDto } from './dto/store.dto';
import { SellerDto } from './dto/seller.dto';
import { BillingDto } from './dto/billing.dto';
import { CustomerDto } from './dto/customer.dto';
import { RegionDto } from './dto/region.dto';
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],
})
async stores(): Promise<StoreDto[]> {
return this.dataConsultService.stores();
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('sellers')
@ApiOperation({ summary: 'Lista todos os vendedores' })
@ApiResponse({
status: 200,
description: 'Lista de vendedores retornada com sucesso',
type: [SellerDto],
})
async sellers(): Promise<SellerDto[]> {
return this.dataConsultService.sellers();
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('billings')
@ApiOperation({ summary: 'Retorna informações de faturamento' })
@ApiResponse({
status: 200,
description: 'Informações de faturamento retornadas com sucesso',
type: [BillingDto],
})
async billings(): Promise<BillingDto[]> {
return this.dataConsultService.billings();
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@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],
})
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],
})
async products(@Param('filter') filter: string): Promise<ProductDto[]> {
return this.dataConsultService.products(filter);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@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();
}
@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],
})
@UsePipes(new ValidationPipe({ transform: true }))
async getAllCarriers(): Promise<CarrierDto[]> {
return this.dataConsultService.getAllCarriers();
}
@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],
})
@UsePipes(new ValidationPipe({ transform: true }))
async getCarriersByDate(
@Query() query: FindCarriersDto,
): Promise<CarrierDto[]> {
return this.dataConsultService.getCarriersByDate(query);
}
@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],
})
@UsePipes(new ValidationPipe({ transform: true }))
async getOrderCarriers(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<CarrierDto[]> {
return this.dataConsultService.getOrderCarriers(orderId);
}
@Get('regions')
@ApiOperation({ summary: 'Lista todas as regiões cadastradas' })
@ApiResponse({
status: 200,
description: 'Lista de regiões retornada com sucesso',
type: [RegionDto],
})
async getRegions(): Promise<RegionDto[]> {
return this.dataConsultService.getRegions();
}
}

View File

@@ -1,19 +1,14 @@
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],
controllers: [DataConsultController],
providers: [
DataConsultService,
DataConsultRepository,
],
})
export class DataConsultModule {}
import { Module } from '@nestjs/common';
import { DataConsultService } from './data-consult.service';
import { DataConsultController } from './data-consult.controller';
import { DataConsultRepository } from './data-consult.repository';
import { ConfigModule } from '@nestjs/config';
import { RedisModule } from 'src/core/configs/cache/redis.module';
import { clientes } from './clientes.module';
@Module({
imports: [ConfigModule, RedisModule, clientes],
controllers: [DataConsultController],
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,10 +37,10 @@ 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[]> {
async findSellers(): Promise<SellerDto[]> {
const sql = `
SELECT PCUSUARI.CODUSUR as "id",
PCUSUARI.NOME as "name"
@@ -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,331 +1,452 @@
import { Injectable, HttpException, HttpStatus, Inject } from '@nestjs/common';
import { DataConsultRepository } from './data-consult.repository';
import { StoreDto } from './dto/store.dto';
import { SellerDto } from './dto/seller.dto';
import { BillingDto } from './dto/billing.dto';
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';
import { DataSource } from 'typeorm';
import { DATA_SOURCE } from '../core/constants';
@Injectable()
export class DataConsultService {
private readonly SELLERS_CACHE_KEY = 'data-consult:sellers';
private readonly SELLERS_TTL = 3600;
private readonly STORES_TTL = 3600;
private readonly BILLINGS_TTL = 3600;
private readonly ALL_PRODUCTS_CACHE_KEY = 'data-consult:products:all';
private readonly ALL_PRODUCTS_TTL = 600;
private readonly CUSTOMERS_TTL = 3600;
private readonly CARRIERS_CACHE_KEY = 'data-consult:carriers:all';
private readonly CARRIERS_TTL = 3600;
private readonly REGIONS_CACHE_KEY = 'data-consult:regions';
private readonly REGIONS_TTL = 7200;
constructor(
private readonly repository: DataConsultRepository,
@Inject(RedisClientToken) private readonly redisClient: IRedisClient,
@Inject('LoggerService') private readonly logger: ILogger,
@Inject(DATA_SOURCE) private readonly dataSource: DataSource
) {}
async stores(): Promise<StoreDto[]> {
this.logger.log('Buscando todas as lojas');
try {
const stores = await this.repository.findStores();
if (stores === null || stores === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
}
const storesArray = Array.isArray(stores) ? stores : [stores];
return storesArray
.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 !== '';
return hasId && hasName && hasStore;
})
.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);
}
}
async sellers(): Promise<SellerDto[]> {
this.logger.log('Buscando vendedores com cache Redis...');
try {
return await getOrSetCache<SellerDto[]>(
this.redisClient,
this.SELLERS_CACHE_KEY,
this.SELLERS_TTL,
async () => {
try {
const sellers = await this.repository.findSellers();
if (sellers === null || sellers === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
}
const sellersArray = Array.isArray(sellers) ? sellers : [sellers];
return sellersArray
.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 !== '';
return hasId && hasName;
})
.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);
}
}
async billings(): Promise<BillingDto[]> {
this.logger.log('Buscando informações de faturamento');
try {
const billings = await this.repository.findBillings();
if (billings === null || billings === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
}
const billingsArray = Array.isArray(billings) ? billings : [billings];
return billingsArray
.filter(billing => {
if (!billing || typeof billing !== 'object') {
return false;
}
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;
return hasId && hasDate && hasTotal;
})
.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);
}
}
async customers(filter: string): Promise<CustomerDto[]> {
this.logger.log(`Buscando clientes com filtro: ${filter}`);
try {
if (!filter || typeof filter !== 'string') {
throw new HttpException('Filtro inválido', HttpStatus.BAD_REQUEST);
}
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);
}
const customersArray = Array.isArray(customers) ? customers : [customers];
return customersArray
.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 !== '';
return hasId && hasName && hasDocument;
})
.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);
}
}
async products(filter: string): Promise<ProductDto[]> {
this.logger.log(`Buscando produtos com filtro: ${filter}`);
try {
if (!filter || typeof filter !== 'string') {
throw new HttpException('Filtro inválido', HttpStatus.BAD_REQUEST);
}
const products = await this.repository.findProducts(filter);
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);
}
}
async getAllProducts(): Promise<ProductDto[]> {
this.logger.log('Buscando todos os produtos');
try {
return await getOrSetCache<ProductDto[]>(
this.redisClient,
this.ALL_PRODUCTS_CACHE_KEY,
this.ALL_PRODUCTS_TTL,
async () => {
try {
const products = await this.repository.findAllProducts();
if (products === null || products === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
}
const productsArray = Array.isArray(products) ? products : [products];
return productsArray
.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 !== '';
return hasId && hasName && hasManufacturerCode;
})
.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);
}
}
async getAllCarriers(): Promise<CarrierDto[]> {
this.logger.log('Buscando todas as transportadoras');
try {
return await getOrSetCache<CarrierDto[]>(
this.redisClient,
this.CARRIERS_CACHE_KEY,
this.CARRIERS_TTL,
async () => {
try {
const carriers = await this.repository.findAllCarriers();
if (carriers === null || carriers === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
}
const carriersArray = Array.isArray(carriers) ? carriers : [carriers];
return carriersArray
.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 !== '';
return hasCarrierId && hasCarrierName && hasCarrierDescription;
})
.map(carrier => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
}));
} catch (error) {
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);
}
}
async getCarriersByDate(query: FindCarriersDto): Promise<CarrierDto[]> {
this.logger.log(`Buscando transportadoras por período: ${JSON.stringify(query)}`);
try {
const carriers = await this.repository.findCarriersByDate(query);
return carriers.map(carrier => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
ordersCount: carrier.ordersCount ? Number(carrier.ordersCount) : 0,
}));
} catch (error) {
this.logger.error('Erro ao buscar transportadoras por período', error);
throw new HttpException('Erro ao buscar transportadoras', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
async getOrderCarriers(orderId: number): Promise<CarrierDto[]> {
this.logger.log(`Buscando transportadoras do pedido: ${orderId}`);
try {
const carriers = await this.repository.findOrderCarriers(orderId);
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);
}
}
async getRegions(): Promise<RegionDto[]> {
this.logger.log('Buscando todas as regiões');
try {
return await getOrSetCache<RegionDto[]>(
this.redisClient,
this.REGIONS_CACHE_KEY,
this.REGIONS_TTL,
async () => {
try {
const regions = await this.repository.findRegions();
if (regions === null || regions === undefined) {
throw new HttpException('Resultado inválido do repositório', HttpStatus.INTERNAL_SERVER_ERROR);
}
const regionsArray = Array.isArray(regions) ? regions : [regions];
return regionsArray
.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 !== '';
return hasNumregiao && hasRegiao;
})
.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);
}
}
}
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';
import { BillingDto } from './dto/billing.dto';
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 { RedisClientToken } from '../core/configs/cache/redis-client.adapter.provider';
import { IRedisClient } from '../core/configs/cache/IRedisClient';
import { getOrSetCache } from '../shared/cache.util';
import { DataSource } from 'typeorm';
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;
private readonly BILLINGS_TTL = 3600;
private readonly ALL_PRODUCTS_CACHE_KEY = 'data-consult:products:all';
private readonly ALL_PRODUCTS_TTL = 600;
private readonly CUSTOMERS_TTL = 3600;
private readonly CARRIERS_CACHE_KEY = 'data-consult:carriers:all';
private readonly CARRIERS_TTL = 3600;
private readonly REGIONS_CACHE_KEY = 'data-consult:regions';
private readonly REGIONS_TTL = 7200;
constructor(
private readonly repository: DataConsultRepository,
@Inject(RedisClientToken) private readonly redisClient: IRedisClient,
@Inject(DATA_SOURCE) private readonly dataSource: DataSource,
) {}
async stores(): Promise<StoreDto[]> {
this.logger.log('Buscando todas as lojas');
try {
const stores = await this.repository.findStores();
if (stores === null || stores === undefined) {
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const storesArray = Array.isArray(stores) ? stores : [stores];
return storesArray
.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 !== '';
return hasId && hasName && hasStore;
})
.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,
);
}
}
async sellers(): Promise<SellerDto[]> {
this.logger.log('Buscando vendedores com cache Redis...');
try {
return await getOrSetCache<SellerDto[]>(
this.redisClient,
this.SELLERS_CACHE_KEY,
this.SELLERS_TTL,
async () => {
try {
const sellers = await this.repository.findSellers();
if (sellers === null || sellers === undefined) {
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const sellersArray = Array.isArray(sellers) ? sellers : [sellers];
return sellersArray
.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 !== '';
return hasId && hasName;
})
.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,
);
}
}
async billings(): Promise<BillingDto[]> {
this.logger.log('Buscando informações de faturamento');
try {
const billings = await this.repository.findBillings();
if (billings === null || billings === undefined) {
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const billingsArray = Array.isArray(billings) ? billings : [billings];
return billingsArray
.filter((billing) => {
if (!billing || typeof billing !== 'object') {
return false;
}
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;
return hasId && hasDate && hasTotal;
})
.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,
);
}
}
async customers(filter: string): Promise<CustomerDto[]> {
this.logger.log(`Buscando clientes com filtro: ${filter}`);
try {
if (!filter || typeof filter !== 'string') {
throw new HttpException('Filtro inválido', HttpStatus.BAD_REQUEST);
}
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,
);
}
const customersArray = Array.isArray(customers) ? customers : [customers];
return customersArray
.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 !== '';
return hasId && hasName && hasDocument;
})
.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,
);
}
}
async products(filter: string): Promise<ProductDto[]> {
this.logger.log(`Buscando produtos com filtro: ${filter}`);
try {
if (!filter || typeof filter !== 'string') {
throw new HttpException('Filtro inválido', HttpStatus.BAD_REQUEST);
}
const products = await this.repository.findProducts(filter);
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,
);
}
}
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,
);
}
}
async getAllProducts(): Promise<ProductDto[]> {
this.logger.log('Buscando todos os produtos');
try {
return await getOrSetCache<ProductDto[]>(
this.redisClient,
this.ALL_PRODUCTS_CACHE_KEY,
this.ALL_PRODUCTS_TTL,
async () => {
try {
const products = await this.repository.findAllProducts();
if (products === null || products === undefined) {
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const productsArray = Array.isArray(products)
? products
: [products];
return productsArray
.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 !== '';
return hasId && hasName && hasManufacturerCode;
})
.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,
);
}
}
async getAllCarriers(): Promise<CarrierDto[]> {
this.logger.log('Buscando todas as transportadoras');
try {
return await getOrSetCache<CarrierDto[]>(
this.redisClient,
this.CARRIERS_CACHE_KEY,
this.CARRIERS_TTL,
async () => {
try {
const carriers = await this.repository.findAllCarriers();
if (carriers === null || carriers === undefined) {
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const carriersArray = Array.isArray(carriers)
? carriers
: [carriers];
return carriersArray
.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 !== '';
return hasCarrierId && hasCarrierName && hasCarrierDescription;
})
.map((carrier) => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
}));
} catch (error) {
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,
);
}
}
async getCarriersByDate(query: FindCarriersDto): Promise<CarrierDto[]> {
this.logger.log(
`Buscando transportadoras por período: ${JSON.stringify(query)}`,
);
try {
const carriers = await this.repository.findCarriersByDate(query);
return carriers.map((carrier) => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
ordersCount: carrier.ordersCount ? Number(carrier.ordersCount) : 0,
}));
} catch (error) {
this.logger.error('Erro ao buscar transportadoras por período', error);
throw new HttpException(
'Erro ao buscar transportadoras',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getOrderCarriers(orderId: number): Promise<CarrierDto[]> {
this.logger.log(`Buscando transportadoras do pedido: ${orderId}`);
try {
const carriers = await this.repository.findOrderCarriers(orderId);
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,
);
}
}
async getRegions(): Promise<RegionDto[]> {
this.logger.log('Buscando todas as regiões');
try {
return await getOrSetCache<RegionDto[]>(
this.redisClient,
this.REGIONS_CACHE_KEY,
this.REGIONS_TTL,
async () => {
try {
const regions = await this.repository.findRegions();
if (regions === null || regions === undefined) {
throw new HttpException(
'Resultado inválido do repositório',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const regionsArray = Array.isArray(regions) ? regions : [regions];
return regionsArray
.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 !== '';
return hasNumregiao && hasRegiao;
})
.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,
);
}
}
}

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,9 +50,9 @@ export class FindCarriersDto {
@ApiProperty({
description: 'ID da filial',
example: '1',
required: false
required: false,
})
@IsOptional()
@IsString()
codfilial?: string;
}
}

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,347 +1,344 @@
import { Get, HttpException, HttpStatus, Injectable, Query, UseGuards } 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';
import { DataSource } from 'typeorm';
import { CarInDelivery } from '../core/models/car-in-delivery.model';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class LogisticService {
constructor(private readonly configService: ConfigService) {}
async getExpedicao() {
const dataSource = new DataSource(createPostgresConfig(this.configService));
await dataSource.initialize();
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
where v.numero_carga = c2.numero
and c2.data_integracao >= TO_DATE('01/02/2025', 'DD/MM/YYYY')
and TO_DATE(RIGHT(c2.observacao, 10), 'DD/MM/YYYY') = dados.dataHoje
and v.embarcado = 'S' ) quantidade_cargas_embarcadas
FROM ( select date_trunc('day', (CURRENT_DATE + INTERVAL '1 day'))::date data_saida, --TO_DATE(RIGHT(c.observacao, 10), 'DD/MM/YYYY') data_saida,
date_trunc('day', (CURRENT_DATE + INTERVAL '1 day'))::date dataHoje,
SUM(c.qt_itens_conferidos) total_itens_conferidos,
SUM(c.qt_itens_separados) total_itens_separados,
SUM(c.qt_total_itens) quantidade_total_itens,
SUM(c.qt_total_pedidos) quantidade_total,
SUM(m.qt * p.peso_unidade) total_kg,
COUNT(DISTINCT c.numero) quantidade_cargas,
COUNT(DISTINCT (CASE WHEN m.data_fim_separacao is not null then c.numero else null end)) quantidade_cargas_separacao_finalizadas,
COUNT(DISTINCT (CASE WHEN m.data_fim_conferencia is not null then c.numero else null end)) quantidade_cargas_conferencia_finalizadas,
SUM(case when m.data_inicio_separacao is null then m.qt * p.peso_unidade else 0 end) total_peso_separacao_nao_iniciada,
SUM(case when m.data_inicio_separacao is not null and m.data_fim_separacao is null then m.qt * p.peso_unidade else 0 end) total_peso_em_separacao,
SUM(case when m.data_fim_separacao is not null then m.qt * p.peso_unidade else 0 end) total_peso_separado,
SUM(case when m.data_fim_separacao is not null and m.data_inicio_conferencia is null then m.qt * p.peso_unidade else 0 end) total_conferencia_nao_iniciada,
SUM(case when m.data_fim_separacao is not null and m.data_inicio_conferencia is not null and m.data_fim_conferencia is null then m.qt * p.peso_unidade else 0 end) total_peso_em_conferencia,
SUM(case when m.data_fim_conferencia is not null then m.qt * p.peso_unidade else 0 end) total_peso_conferido
from movimentacao m , carga c , produto p
where m.numero_carga = c.numero
and m.produto_id = p.id
and m.data_integracao >= TO_DATE('01/01/2025', 'DD/MM/YYYY')
and c.data_faturamento IS NULL
and c.destino not like '%TRANSF%'
and m.empresa_id in ( 3, 4 )
--group by TO_DATE(RIGHT(c.observacao, 10), 'DD/MM/YYYY')
) dados
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"
,SUM(CASE WHEN PCPEDC.DTINICIALSEP IS NOT NULL
AND PCPEDC.DTFINALSEP IS NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_em_separacao"
,SUM(CASE WHEN PCPEDC.DTFINALSEP IS NOT NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_separado"
,SUM(CASE WHEN PCPEDC.DTFINALSEP IS NOT NULL
AND PCPEDC.DTINICIALCHECKOUT IS NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_conferencia_nao_iniciada"
,SUM(CASE WHEN PCPEDC.DTFINALSEP IS NOT NULL
AND PCPEDC.DTINICIALCHECKOUT IS NOT NULL
AND PCPEDC.DTFINALCHECKOUT IS NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_em_conferencia"
,SUM(CASE WHEN PCPEDC.DTFINALSEP IS NOT NULL
AND PCPEDC.DTFINALCHECKOUT IS NOT NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_coferencia_finalizada"
FROM PCPEDI, PCPEDC, PCPRODUT, PCCARREG
WHERE PCPEDI.NUMPED = PCPEDC.NUMPED
AND PCPEDI.CODPROD = PCPRODUT.CODPROD
AND PCPEDI.NUMCAR = PCCARREG.NUMCAR
AND PCPEDC.CODFILIAL = 12
AND PCPEDI.TIPOENTREGA IN ('EN', 'EF')
AND PCCARREG.DTSAIDA = TRUNC(SYSDATE)`;
const mov = await queryRunner.manager.query(sqlWMS);
const hoje = new Date();
let amanha = new Date(hoje);
amanha.setDate(hoje.getDate() + 1);
const amanhaString = amanha.toISOString().split('T')[0];
amanha = new Date(amanhaString);
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]);
return movFiltered;
} catch (e) {
console.log(e);
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
async getDeliveries(placa: string) {
const dataSource = new DataSource(createOracleConfig(this.configService));
await dataSource.initialize();
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT PCCARREG.NUMCAR as "id"
,PCCARREG.DTSAIDA as "createDate"
,PCCARREG.DESTINO as "comment"
,PCCARREG.TOTPESO as "weight"
,PCCARREG.NUMNOTAS as "invoices"
,( SELECT COUNT(DISTINCT NVL(PCCLIENTENDENT.CODPRACAENT, PCPEDC.CODPRACA))
FROM PCPEDC, PCCLIENTENDENT
WHERE PCPEDC.NUMCAR = PCCARREG.NUMCAR
AND PCPEDC.CODENDENTCLI = PCCLIENTENDENT.CODENDENTCLI (+) ) as "citys"
,( SELECT COUNT(DISTINCT PCPEDC.CODCLI) FROM PCPEDC
WHERE PCPEDC.NUMCAR = PCCARREG.NUMCAR) as "deliveries"
,PCCARREG.CODMOTORISTA as "driverId"
,PCEMPR.NOME as "driverName"
,PCVEICUL.CODVEICULO as "carId"
,PCVEICUL.DESCRICAO as "carDescription"
,PCVEICUL.PLACA as "identification"
,PCCARREG.CODFUNCAJUD as "helperId"
,PCCARREG.CODFUNCAJUD2 as "helperId1"
,PCCARREG.CODFUNCAJUD3 as "helperId2"
FROM PCCARREG, PCVEICUL, PCEMPR
WHERE PCCARREG.CODVEICULO = PCVEICUL.codveiculo (+)
AND PCCARREG.CODMOTORISTA = PCEMPR.MATRICULA (+)
AND PCCARREG.DTFECHA IS NULL
AND PCCARREG.DTSAIDA >= TRUNC(SYSDATE)`;
const deliveries = await queryRunner.manager.query(sql);
return deliveries;
} catch (e) {
console.log(e);
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
async getStatusCar(placa: string) {
const dataSource = new DataSource(createPostgresConfig(this.configService));
await dataSource.initialize();
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}'
AND ESTSAIDAVEICULO.DTRETORNO IS NULL`;
const outCar = await queryRunner.manager.query(sql);
return { veiculoEmViagem: ( outCar.length > 0 ) ? true : false };
} catch (e) {
console.log(e);
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
async getEmployee() {
const dataSource = new DataSource(createOracleConfig(this.configService));
await dataSource.initialize();
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT PCEMPR.MATRICULA as "id"
,PCEMPR.NOME as "name"
,PCEMPR.FUNCAO as "fuctionName"
FROM PCEMPR, PCCONSUM
WHERE PCEMPR.DTDEMISSAO IS NULL
AND PCEMPR.CODSETOR = PCCONSUM.CODSETOREXPED
ORDER BY PCEMPR.NOME `;
const dataEmployee = await queryRunner.query(sql);
return dataEmployee;
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
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;
let helperId1 = 0;
let helperId2 = 0;
let helperId3 = 0;
const image1 = '';
const image2 = '';
const image3 = '';
const image4 = '';
data.helpers.forEach(helper => {
switch (i) {
case 0:
helperId1 = helper.id;
break;
case 1:
helperId2 = helper.id;
break;
case 2:
helperId3 = helper.id;
break;
}
i++;
});
for (let y = 0; y < data.photos.length; y++) {
const sqlImage = `INSERT INTO ESTSAIDAVEICULOIMAGENS ( CODSAIDA, TIPO, URL )
VALUES (${dataSequence[0].id}, 'SA', '${data.photos[y]}' )`;
await queryRunner.query(sqlImage);
}
const sqlSaidaVeiculo = `INSERT INTO ESTSAIDAVEICULO ( CODSAIDA, CODVEICULO, DTSAIDA, QTAJUDANTES, CODFUNCSAIDA )
VALUES ( ${dataSequence[0].id}, ${data.vehicleCode}, SYSDATE, ${data.helpers.length},
${data.userCode} )`;
await queryRunner.query(sqlSaidaVeiculo);
for (let y = 0; y < data.numberLoading.length; y++) {
const sqlLoading = `INSERT INTO ESTSAIDAVEICULOCARREG ( CODSAIDA, NUMCAR )
VALUES ( ${dataSequence[0].id}, ${data.numberLoading[y]})`;
await queryRunner.query(sqlLoading);
const sql = `UPDATE PCCARREG SET
DTSAIDAVEICULO = SYSDATE
,CODFUNCAJUD = ${helperId1}
,CODFUNCAJUD2 = ${helperId2}
,CODFUNCAJUD3 = ${helperId3}
,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!'}
} catch (e) {
await queryRunner.rollbackTransaction();
throw e;
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
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
AND PCCARREG.NUMCAR = ESTSAIDAVEICULOCARREG.NUMCAR
AND ESTSAIDAVEICULOCARREG.CODSAIDA = ESTSAIDAVEICULO.CODSAIDA
-- AND ESTSAIDAVEICULO.DTRETORNO IS NULL
AND PCVEICUL.PLACA = '${data.licensePlate}'`;
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 );
}
const i = 0;
const image1 = '';
const image2 = '';
const image3 = '';
const image4 = '';
for (let y = 0; y < data.invoices.length; y++) {
const invoice = data.invoices[y];
const sqlInvoice = `INSERT INTO ESTRETORNONF ( CODSAIDA, NUMCAR, NUMNOTA, SITUACAO, MOTIVO )
VALUES ( ${dataOutCar[0].id}, ${invoice.loadingNumber}, ${invoice.invoiceNumber},
'${invoice.status}', '${invoice.reasonText}')`;
await queryRunner.query(sqlInvoice);
}
const updateCarreg = `UPDATE PCCARREG SET
PCCARREG.DTRETORNO = SYSDATE
,PCCARREG.KMFINAL = ${data.finalKm}
WHERE PCCARREG.NUMCAR IN ( SELECT SC.NUMCAR
FROM ESTSAIDAVEICULOCARREG SC
WHERE SC.CODSAIDA = ${dataOutCar[0].id} )`;
await queryRunner.query(updateCarreg);
for (let i = 0; i < data.images.length; i++) {
const sqlImage = `INSERT INTO ESTSAIDAVEICULOIMAGENS ( CODSAIDA, TIPO, URL )
VALUES (${dataOutCar[0].id}, 'RE', '${data.images[i]}' )`;
await queryRunner.query(sqlImage);
}
const sqlInCar = `UPDATE ESTSAIDAVEICULO SET
ESTSAIDAVEICULO.DTRETORNO = SYSDATE
,ESTSAIDAVEICULO.QTPALETES_PBR = ${data.qtdPaletesPbr}
,ESTSAIDAVEICULO.QTPALETES_CIM = ${data.qtdPaletesCim}
,ESTSAIDAVEICULO.QTPALETES_DES = ${data.qtdPaletesDes}
,ESTSAIDAVEICULO.codfuncretorno = ${data.userId}
,ESTSAIDAVEICULO.obsretorno = '${data.observation}'
,ESTSAIDAVEICULO.HOUVESOBRA = '${data.remnant}'
,ESTSAIDAVEICULO.OBSSOBRA = '${data.observationRemnant}'
WHERE ESTSAIDAVEICULO.CODSAIDA = ${dataOutCar[0].id}`;
await queryRunner.query(sqlInCar);
for (let i = 0; i < data.imagesRemnant.length; i++) {
const sqlImage = `INSERT INTO ESTSAIDAVEICULOIMAGENS ( CODSAIDA, TIPO, URL )
VALUES (${dataOutCar[0].id}, 'SO', '${data.imagesRemnant[i]}' )`;
await queryRunner.query(sqlImage);
}
await queryRunner.commitTransaction();
return { message: 'Dados de retorno do veículo gravada com sucesso!'}
} catch (e) {
await queryRunner.rollbackTransaction();
console.log(e);
throw e;
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
}
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';
import { DataSource } from 'typeorm';
import { CarInDelivery } from '../core/models/car-in-delivery.model';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class LogisticService {
constructor(private readonly configService: ConfigService) {}
async getExpedicao() {
const dataSource = new DataSource(createPostgresConfig(this.configService));
await dataSource.initialize();
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
where v.numero_carga = c2.numero
and c2.data_integracao >= TO_DATE('01/02/2025', 'DD/MM/YYYY')
and TO_DATE(RIGHT(c2.observacao, 10), 'DD/MM/YYYY') = dados.dataHoje
and v.embarcado = 'S' ) quantidade_cargas_embarcadas
FROM ( select date_trunc('day', (CURRENT_DATE + INTERVAL '1 day'))::date data_saida, --TO_DATE(RIGHT(c.observacao, 10), 'DD/MM/YYYY') data_saida,
date_trunc('day', (CURRENT_DATE + INTERVAL '1 day'))::date dataHoje,
SUM(c.qt_itens_conferidos) total_itens_conferidos,
SUM(c.qt_itens_separados) total_itens_separados,
SUM(c.qt_total_itens) quantidade_total_itens,
SUM(c.qt_total_pedidos) quantidade_total,
SUM(m.qt * p.peso_unidade) total_kg,
COUNT(DISTINCT c.numero) quantidade_cargas,
COUNT(DISTINCT (CASE WHEN m.data_fim_separacao is not null then c.numero else null end)) quantidade_cargas_separacao_finalizadas,
COUNT(DISTINCT (CASE WHEN m.data_fim_conferencia is not null then c.numero else null end)) quantidade_cargas_conferencia_finalizadas,
SUM(case when m.data_inicio_separacao is null then m.qt * p.peso_unidade else 0 end) total_peso_separacao_nao_iniciada,
SUM(case when m.data_inicio_separacao is not null and m.data_fim_separacao is null then m.qt * p.peso_unidade else 0 end) total_peso_em_separacao,
SUM(case when m.data_fim_separacao is not null then m.qt * p.peso_unidade else 0 end) total_peso_separado,
SUM(case when m.data_fim_separacao is not null and m.data_inicio_conferencia is null then m.qt * p.peso_unidade else 0 end) total_conferencia_nao_iniciada,
SUM(case when m.data_fim_separacao is not null and m.data_inicio_conferencia is not null and m.data_fim_conferencia is null then m.qt * p.peso_unidade else 0 end) total_peso_em_conferencia,
SUM(case when m.data_fim_conferencia is not null then m.qt * p.peso_unidade else 0 end) total_peso_conferido
from movimentacao m , carga c , produto p
where m.numero_carga = c.numero
and m.produto_id = p.id
and m.data_integracao >= TO_DATE('01/01/2025', 'DD/MM/YYYY')
and c.data_faturamento IS NULL
and c.destino not like '%TRANSF%'
and m.empresa_id in ( 3, 4 )
--group by TO_DATE(RIGHT(c.observacao, 10), 'DD/MM/YYYY')
) dados
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"
,SUM(CASE WHEN PCPEDC.DTINICIALSEP IS NOT NULL
AND PCPEDC.DTFINALSEP IS NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_em_separacao"
,SUM(CASE WHEN PCPEDC.DTFINALSEP IS NOT NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_separado"
,SUM(CASE WHEN PCPEDC.DTFINALSEP IS NOT NULL
AND PCPEDC.DTINICIALCHECKOUT IS NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_conferencia_nao_iniciada"
,SUM(CASE WHEN PCPEDC.DTFINALSEP IS NOT NULL
AND PCPEDC.DTINICIALCHECKOUT IS NOT NULL
AND PCPEDC.DTFINALCHECKOUT IS NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_em_conferencia"
,SUM(CASE WHEN PCPEDC.DTFINALSEP IS NOT NULL
AND PCPEDC.DTFINALCHECKOUT IS NOT NULL THEN PCPEDI.QT ELSE 0 END * PCPRODUT.PESOBRUTO) as "total_coferencia_finalizada"
FROM PCPEDI, PCPEDC, PCPRODUT, PCCARREG
WHERE PCPEDI.NUMPED = PCPEDC.NUMPED
AND PCPEDI.CODPROD = PCPRODUT.CODPROD
AND PCPEDI.NUMCAR = PCCARREG.NUMCAR
AND PCPEDC.CODFILIAL = 12
AND PCPEDI.TIPOENTREGA IN ('EN', 'EF')
AND PCCARREG.DTSAIDA = TRUNC(SYSDATE)`;
const mov = await queryRunner.manager.query(sqlWMS);
const hoje = new Date();
let amanha = new Date(hoje);
amanha.setDate(hoje.getDate() + 1);
const amanhaString = amanha.toISOString().split('T')[0];
amanha = new Date(amanhaString);
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],
);
return movFiltered;
} catch (e) {
console.log(e);
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
async getDeliveries(placa: string) {
const dataSource = new DataSource(createOracleConfig(this.configService));
await dataSource.initialize();
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT PCCARREG.NUMCAR as "id"
,PCCARREG.DTSAIDA as "createDate"
,PCCARREG.DESTINO as "comment"
,PCCARREG.TOTPESO as "weight"
,PCCARREG.NUMNOTAS as "invoices"
,( SELECT COUNT(DISTINCT NVL(PCCLIENTENDENT.CODPRACAENT, PCPEDC.CODPRACA))
FROM PCPEDC, PCCLIENTENDENT
WHERE PCPEDC.NUMCAR = PCCARREG.NUMCAR
AND PCPEDC.CODENDENTCLI = PCCLIENTENDENT.CODENDENTCLI (+) ) as "citys"
,( SELECT COUNT(DISTINCT PCPEDC.CODCLI) FROM PCPEDC
WHERE PCPEDC.NUMCAR = PCCARREG.NUMCAR) as "deliveries"
,PCCARREG.CODMOTORISTA as "driverId"
,PCEMPR.NOME as "driverName"
,PCVEICUL.CODVEICULO as "carId"
,PCVEICUL.DESCRICAO as "carDescription"
,PCVEICUL.PLACA as "identification"
,PCCARREG.CODFUNCAJUD as "helperId"
,PCCARREG.CODFUNCAJUD2 as "helperId1"
,PCCARREG.CODFUNCAJUD3 as "helperId2"
FROM PCCARREG, PCVEICUL, PCEMPR
WHERE PCCARREG.CODVEICULO = PCVEICUL.codveiculo (+)
AND PCCARREG.CODMOTORISTA = PCEMPR.MATRICULA (+)
AND PCCARREG.DTFECHA IS NULL
AND PCCARREG.DTSAIDA >= TRUNC(SYSDATE)`;
const deliveries = await queryRunner.manager.query(sql);
return deliveries;
} catch (e) {
console.log(e);
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
async getStatusCar(placa: string) {
const dataSource = new DataSource(createPostgresConfig(this.configService));
await dataSource.initialize();
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}'
AND ESTSAIDAVEICULO.DTRETORNO IS NULL`;
const outCar = await queryRunner.manager.query(sql);
return { veiculoEmViagem: outCar.length > 0 ? true : false };
} catch (e) {
console.log(e);
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
async getEmployee() {
const dataSource = new DataSource(createOracleConfig(this.configService));
await dataSource.initialize();
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT PCEMPR.MATRICULA as "id"
,PCEMPR.NOME as "name"
,PCEMPR.FUNCAO as "fuctionName"
FROM PCEMPR, PCCONSUM
WHERE PCEMPR.DTDEMISSAO IS NULL
AND PCEMPR.CODSETOR = PCCONSUM.CODSETOREXPED
ORDER BY PCEMPR.NOME `;
const dataEmployee = await queryRunner.query(sql);
return dataEmployee;
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
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;
let helperId1 = 0;
let helperId2 = 0;
let helperId3 = 0;
const image1 = '';
const image2 = '';
const image3 = '';
const image4 = '';
data.helpers.forEach((helper) => {
switch (i) {
case 0:
helperId1 = helper.id;
break;
case 1:
helperId2 = helper.id;
break;
case 2:
helperId3 = helper.id;
break;
}
i++;
});
for (let y = 0; y < data.photos.length; y++) {
const sqlImage = `INSERT INTO ESTSAIDAVEICULOIMAGENS ( CODSAIDA, TIPO, URL )
VALUES (${dataSequence[0].id}, 'SA', '${data.photos[y]}' )`;
await queryRunner.query(sqlImage);
}
const sqlSaidaVeiculo = `INSERT INTO ESTSAIDAVEICULO ( CODSAIDA, CODVEICULO, DTSAIDA, QTAJUDANTES, CODFUNCSAIDA )
VALUES ( ${dataSequence[0].id}, ${data.vehicleCode}, SYSDATE, ${data.helpers.length},
${data.userCode} )`;
await queryRunner.query(sqlSaidaVeiculo);
for (let y = 0; y < data.numberLoading.length; y++) {
const sqlLoading = `INSERT INTO ESTSAIDAVEICULOCARREG ( CODSAIDA, NUMCAR )
VALUES ( ${dataSequence[0].id}, ${data.numberLoading[y]})`;
await queryRunner.query(sqlLoading);
const sql = `UPDATE PCCARREG SET
DTSAIDAVEICULO = SYSDATE
,CODFUNCAJUD = ${helperId1}
,CODFUNCAJUD2 = ${helperId2}
,CODFUNCAJUD3 = ${helperId3}
,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!' };
} catch (e) {
await queryRunner.rollbackTransaction();
throw e;
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
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
AND PCCARREG.NUMCAR = ESTSAIDAVEICULOCARREG.NUMCAR
AND ESTSAIDAVEICULOCARREG.CODSAIDA = ESTSAIDAVEICULO.CODSAIDA
-- AND ESTSAIDAVEICULO.DTRETORNO IS NULL
AND PCVEICUL.PLACA = '${data.licensePlate}'`;
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,
);
}
const i = 0;
const image1 = '';
const image2 = '';
const image3 = '';
const image4 = '';
for (let y = 0; y < data.invoices.length; y++) {
const invoice = data.invoices[y];
const sqlInvoice = `INSERT INTO ESTRETORNONF ( CODSAIDA, NUMCAR, NUMNOTA, SITUACAO, MOTIVO )
VALUES ( ${dataOutCar[0].id}, ${invoice.loadingNumber}, ${invoice.invoiceNumber},
'${invoice.status}', '${invoice.reasonText}')`;
await queryRunner.query(sqlInvoice);
}
const updateCarreg = `UPDATE PCCARREG SET
PCCARREG.DTRETORNO = SYSDATE
,PCCARREG.KMFINAL = ${data.finalKm}
WHERE PCCARREG.NUMCAR IN ( SELECT SC.NUMCAR
FROM ESTSAIDAVEICULOCARREG SC
WHERE SC.CODSAIDA = ${dataOutCar[0].id} )`;
await queryRunner.query(updateCarreg);
for (let i = 0; i < data.images.length; i++) {
const sqlImage = `INSERT INTO ESTSAIDAVEICULOIMAGENS ( CODSAIDA, TIPO, URL )
VALUES (${dataOutCar[0].id}, 'RE', '${data.images[i]}' )`;
await queryRunner.query(sqlImage);
}
const sqlInCar = `UPDATE ESTSAIDAVEICULO SET
ESTSAIDAVEICULO.DTRETORNO = SYSDATE
,ESTSAIDAVEICULO.QTPALETES_PBR = ${data.qtdPaletesPbr}
,ESTSAIDAVEICULO.QTPALETES_CIM = ${data.qtdPaletesCim}
,ESTSAIDAVEICULO.QTPALETES_DES = ${data.qtdPaletesDes}
,ESTSAIDAVEICULO.codfuncretorno = ${data.userId}
,ESTSAIDAVEICULO.obsretorno = '${data.observation}'
,ESTSAIDAVEICULO.HOUVESOBRA = '${data.remnant}'
,ESTSAIDAVEICULO.OBSSOBRA = '${data.observationRemnant}'
WHERE ESTSAIDAVEICULO.CODSAIDA = ${dataOutCar[0].id}`;
await queryRunner.query(sqlInCar);
for (let i = 0; i < data.imagesRemnant.length; i++) {
const sqlImage = `INSERT INTO ESTSAIDAVEICULOIMAGENS ( CODSAIDA, TIPO, URL )
VALUES (${dataOutCar[0].id}, 'SO', '${data.imagesRemnant[i]}' )`;
await queryRunner.query(sqlImage);
}
await queryRunner.commitTransaction();
return { message: 'Dados de retorno do veículo gravada com sucesso!' };
} catch (e) {
await queryRunner.rollbackTransaction();
console.log(e);
throw e;
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
}

View File

@@ -12,30 +12,37 @@ async function bootstrap() {
* Configura timezone para horário brasileiro
*/
process.env.TZ = 'America/Sao_Paulo';
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: [`'self'`],
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'],
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: [`'self'`],
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'), {
index: false,
prefix: '/dashboard',
});
app.useGlobalInterceptors(new ResponseInterceptor());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
@@ -56,19 +63,16 @@ async function bootstrap() {
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
});
const config = new DocumentBuilder()
.setTitle('Portal Jurunense API')
.setDescription('Documentação da API do Portal Jurunense')
.setVersion('1.0')
.addBearerAuth()
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
SwaggerModule.setup('docs', app, document);
await app.listen(8066);
}
bootstrap();

View File

@@ -14,4 +14,4 @@ export class CreateInvoiceDto {
required: true,
})
userId: number;
}
}

View File

@@ -38,7 +38,7 @@ export class CreatePaymentDto {
@ApiProperty({
description: 'Valor do pagamento',
example: 1000.00,
example: 1000.0,
required: true,
})
amount: number;
@@ -63,4 +63,4 @@ export class CreatePaymentDto {
required: true,
})
userId: 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,11 +81,11 @@ export class OrderDto {
@ApiProperty({
description: 'Valor total pago',
example: 1000.00,
example: 1000.0,
})
amountPaid: number;
constructor(partial: Partial<OrderDto>) {
Object.assign(this, partial);
}
}
}

View File

@@ -39,7 +39,7 @@ export class PaymentDto {
@ApiProperty({
description: 'Valor do pagamento',
example: 1000.00,
example: 1000.0,
})
amount: number;
@@ -64,4 +64,4 @@ export class PaymentDto {
constructor(partial: Partial<PaymentDto>) {
Object.assign(this, partial);
}
}
}

View File

@@ -1,77 +1,82 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
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';
import { CreatePaymentDto } from './dto/create-payment.dto';
import { CreateInvoiceDto } from './dto/create-invoice.dto';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
@ApiTags('Orders Payment')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('api/v1/orders-payment')
export class OrdersPaymentController {
constructor(private readonly orderPaymentService: OrdersPaymentService){}
@Get('orders/:id')
@ApiOperation({ summary: 'Lista todos os pedidos de uma loja' })
@ApiParam({ name: 'id', description: 'ID da loja' })
@ApiResponse({
status: 200,
description: 'Lista de pedidos retornada com sucesso',
type: [OrderDto]
})
async findOrders(@Param('id') storeId: string): Promise<OrderDto[]> {
return this.orderPaymentService.findOrders(storeId, 0);
}
@Get('orders/:id/:orderId')
@ApiOperation({ summary: 'Busca um pedido específico' })
@ApiParam({ name: 'id', description: 'ID da loja' })
@ApiParam({ name: 'orderId', description: 'ID do pedido' })
@ApiResponse({
status: 200,
description: 'Pedido retornado com sucesso',
type: OrderDto
})
async findOrder(
@Param('id') storeId: string,
@Param('orderId') orderId: number,
): Promise<OrderDto> {
const orders = await this.orderPaymentService.findOrders(storeId, orderId);
return orders[0];
}
@Get('payments/:id')
@ApiOperation({ summary: 'Lista todos os pagamentos de um pedido' })
@ApiParam({ name: 'id', description: 'ID do pedido' })
@ApiResponse({
status: 200,
description: 'Lista de pagamentos retornada com sucesso',
type: [PaymentDto]
})
async findPayments(@Param('id') orderId: number): Promise<PaymentDto[]> {
return this.orderPaymentService.findPayments(orderId);
}
@Post('payments/create')
@ApiOperation({ summary: 'Cria um novo pagamento' })
@ApiResponse({
status: 201,
description: 'Pagamento criado com sucesso'
})
async createPayment(@Body() data: CreatePaymentDto): Promise<void> {
return this.orderPaymentService.createPayment(data);
}
@Post('invoice/create')
@ApiOperation({ summary: 'Cria uma nova fatura' })
@ApiResponse({
status: 201,
description: 'Fatura criada com sucesso'
})
async createInvoice(@Body() data: CreateInvoiceDto): Promise<void> {
return this.orderPaymentService.createInvoice(data);
}
}
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
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';
import { CreatePaymentDto } from './dto/create-payment.dto';
import { CreateInvoiceDto } from './dto/create-invoice.dto';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
@ApiTags('Orders Payment')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('api/v1/orders-payment')
export class OrdersPaymentController {
constructor(private readonly orderPaymentService: OrdersPaymentService) {}
@Get('orders/:id')
@ApiOperation({ summary: 'Lista todos os pedidos de uma loja' })
@ApiParam({ name: 'id', description: 'ID da loja' })
@ApiResponse({
status: 200,
description: 'Lista de pedidos retornada com sucesso',
type: [OrderDto],
})
async findOrders(@Param('id') storeId: string): Promise<OrderDto[]> {
return this.orderPaymentService.findOrders(storeId, 0);
}
@Get('orders/:id/:orderId')
@ApiOperation({ summary: 'Busca um pedido específico' })
@ApiParam({ name: 'id', description: 'ID da loja' })
@ApiParam({ name: 'orderId', description: 'ID do pedido' })
@ApiResponse({
status: 200,
description: 'Pedido retornado com sucesso',
type: OrderDto,
})
async findOrder(
@Param('id') storeId: string,
@Param('orderId') orderId: number,
): Promise<OrderDto> {
const orders = await this.orderPaymentService.findOrders(storeId, orderId);
return orders[0];
}
@Get('payments/:id')
@ApiOperation({ summary: 'Lista todos os pagamentos de um pedido' })
@ApiParam({ name: 'id', description: 'ID do pedido' })
@ApiResponse({
status: 200,
description: 'Lista de pagamentos retornada com sucesso',
type: [PaymentDto],
})
async findPayments(@Param('id') orderId: number): Promise<PaymentDto[]> {
return this.orderPaymentService.findPayments(orderId);
}
@Post('payments/create')
@ApiOperation({ summary: 'Cria um novo pagamento' })
@ApiResponse({
status: 201,
description: 'Pagamento criado com sucesso',
})
async createPayment(@Body() data: CreatePaymentDto): Promise<void> {
return this.orderPaymentService.createPayment(data);
}
@Post('invoice/create')
@ApiOperation({ summary: 'Cria uma nova fatura' })
@ApiResponse({
status: 201,
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

@@ -1,120 +1,120 @@
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { DATA_SOURCE } from '../core/constants';
import { OrderDto } from './dto/order.dto';
import { PaymentDto } from './dto/payment.dto';
import { CreatePaymentDto } from './dto/create-payment.dto';
import { CreateInvoiceDto } from './dto/create-invoice.dto';
@Injectable()
export class OrdersPaymentService {
constructor(
private readonly configService: ConfigService,
@Inject(DATA_SOURCE) private readonly dataSource: DataSource
) {}
async findOrders(storeId: string, orderId: number): Promise<OrderDto[]> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT PCPEDC.DATA as "createDate"
,PCPEDC.CODFILIAL as "storeId"
,PCPEDC.NUMPED as "orderId"
,PCPEDC.CODCLI as "customerId"
,PCCLIENT.CLIENTE as "customerName"
,PCPEDC.CODUSUR as "sellerId"
,PCUSUARI.NOME as "sellerName"
,PCPEDC.CODCOB as "billingId"
,PCCOB.COBRANCA as "billingName"
,PCPEDC.CODPLPAG as "planId"
,PCPLPAG.DESCRICAO as "planName"
,ROUND(PCPEDC.VLATEND,2) as "amount"
,NVL(PCPLPAG.NUMPARCELAS,1) as "installments"
,( SELECT SUM(ESTPAGAMENTO.VALOR) FROM ESTPAGAMENTO
WHERE ESTPAGAMENTO.NUMORCA = PCPEDC.NUMPED ) as "amountPaid"
FROM PCPEDC, PCCLIENT, PCUSUARI, PCCOB, PCPLPAG
WHERE PCPEDC.CODCLI = PCCLIENT.CODCLI
AND PCPEDC.CODUSUR = PCUSUARI.CODUSUR
AND PCPEDC.CODPLPAG = PCPLPAG.CODPLPAG
AND PCPEDC.CODCOB = PCCOB.CODCOB
AND PCPEDC.CONDVENDA = 7
AND PCPEDC.POSICAO IN ('L')
AND PCPEDC.DATA >= TRUNC(SYSDATE) - 5
AND PCPEDC.CODFILIAL = ${storeId} `;
let sqlWhere = '';
if (orderId > 0) {
sqlWhere += ` AND PCPEDC.NUMPED = ${orderId}`;
}
const orders = await queryRunner.manager.query(sql + sqlWhere);
return orders.map(order => new OrderDto(order));
} finally {
await queryRunner.release();
}
}
async findPayments(orderId: number): Promise<PaymentDto[]> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT
ESTPAGAMENTO.NUMORCA as "orderId"
,ESTPAGAMENTO.DTPAGAMENTO as "payDate"
,ESTPAGAMENTO.CARTAO as "card"
,ESTPAGAMENTO.PARCELAS as "installments"
,ESTPAGAMENTO.NOMEBANDEIRA as "flagName"
,ESTPAGAMENTO.FORMAPAGTO as "type"
,ESTPAGAMENTO.VALOR as "amount"
,ESTPAGAMENTO.CODFUNC as "userId"
,ESTPAGAMENTO.NSU as "nsu"
,ESTPAGAMENTO.CODAUTORIZACAO as "auth"
FROM ESTPAGAMENTO
WHERE ESTPAGAMENTO.NUMORCA = ${orderId}`;
const payments = await queryRunner.manager.query(sql);
return payments.map(payment => new PaymentDto(payment));
} finally {
await queryRunner.release();
}
}
async createPayment(payment: CreatePaymentDto): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const sql = `INSERT INTO ESTPAGAMENTO ( NUMORCA, DTPAGAMENTO, CARTAO, CODAUTORIZACAO, CODRESPOSTA, DTREQUISICAO, DTSERVIDOR, IDTRANSACAO,
NSU, PARCELAS, VALOR, NOMEBANDEIRA, FORMAPAGTO, DTPROCESSAMENTO, CODFUNC )
VALUES ( ${payment.orderId}, TRUNC(SYSDATE), '${payment.card}', '${payment.auth}', '00', SYSDATE, SYSDATE, NULL,
'${payment.nsu}', ${payment.installments}, ${payment.amount}, '${payment.flagName}',
'${payment.paymentType}', SYSDATE, ${payment.userId} ) `;
await queryRunner.manager.query(sql);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
async createInvoice(data: CreateInvoiceDto): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const sql = `BEGIN
ESK_FATURAMENTO.FATURAMENTO_VENDA_ASSISTIDA(${data.orderId}, ${data.userId});
END;`;
await queryRunner.manager.query(sql);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { DATA_SOURCE } from '../core/constants';
import { OrderDto } from './dto/order.dto';
import { PaymentDto } from './dto/payment.dto';
import { CreatePaymentDto } from './dto/create-payment.dto';
import { CreateInvoiceDto } from './dto/create-invoice.dto';
@Injectable()
export class OrdersPaymentService {
constructor(
private readonly configService: ConfigService,
@Inject(DATA_SOURCE) private readonly dataSource: DataSource,
) {}
async findOrders(storeId: string, orderId: number): Promise<OrderDto[]> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT PCPEDC.DATA as "createDate"
,PCPEDC.CODFILIAL as "storeId"
,PCPEDC.NUMPED as "orderId"
,PCPEDC.CODCLI as "customerId"
,PCCLIENT.CLIENTE as "customerName"
,PCPEDC.CODUSUR as "sellerId"
,PCUSUARI.NOME as "sellerName"
,PCPEDC.CODCOB as "billingId"
,PCCOB.COBRANCA as "billingName"
,PCPEDC.CODPLPAG as "planId"
,PCPLPAG.DESCRICAO as "planName"
,ROUND(PCPEDC.VLATEND,2) as "amount"
,NVL(PCPLPAG.NUMPARCELAS,1) as "installments"
,( SELECT SUM(ESTPAGAMENTO.VALOR) FROM ESTPAGAMENTO
WHERE ESTPAGAMENTO.NUMORCA = PCPEDC.NUMPED ) as "amountPaid"
FROM PCPEDC, PCCLIENT, PCUSUARI, PCCOB, PCPLPAG
WHERE PCPEDC.CODCLI = PCCLIENT.CODCLI
AND PCPEDC.CODUSUR = PCUSUARI.CODUSUR
AND PCPEDC.CODPLPAG = PCPLPAG.CODPLPAG
AND PCPEDC.CODCOB = PCCOB.CODCOB
AND PCPEDC.CONDVENDA = 7
AND PCPEDC.POSICAO IN ('L')
AND PCPEDC.DATA >= TRUNC(SYSDATE) - 5
AND PCPEDC.CODFILIAL = ${storeId} `;
let sqlWhere = '';
if (orderId > 0) {
sqlWhere += ` AND PCPEDC.NUMPED = ${orderId}`;
}
const orders = await queryRunner.manager.query(sql + sqlWhere);
return orders.map((order) => new OrderDto(order));
} finally {
await queryRunner.release();
}
}
async findPayments(orderId: number): Promise<PaymentDto[]> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT
ESTPAGAMENTO.NUMORCA as "orderId"
,ESTPAGAMENTO.DTPAGAMENTO as "payDate"
,ESTPAGAMENTO.CARTAO as "card"
,ESTPAGAMENTO.PARCELAS as "installments"
,ESTPAGAMENTO.NOMEBANDEIRA as "flagName"
,ESTPAGAMENTO.FORMAPAGTO as "type"
,ESTPAGAMENTO.VALOR as "amount"
,ESTPAGAMENTO.CODFUNC as "userId"
,ESTPAGAMENTO.NSU as "nsu"
,ESTPAGAMENTO.CODAUTORIZACAO as "auth"
FROM ESTPAGAMENTO
WHERE ESTPAGAMENTO.NUMORCA = ${orderId}`;
const payments = await queryRunner.manager.query(sql);
return payments.map((payment) => new PaymentDto(payment));
} finally {
await queryRunner.release();
}
}
async createPayment(payment: CreatePaymentDto): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const sql = `INSERT INTO ESTPAGAMENTO ( NUMORCA, DTPAGAMENTO, CARTAO, CODAUTORIZACAO, CODRESPOSTA, DTREQUISICAO, DTSERVIDOR, IDTRANSACAO,
NSU, PARCELAS, VALOR, NOMEBANDEIRA, FORMAPAGTO, DTPROCESSAMENTO, CODFUNC )
VALUES ( ${payment.orderId}, TRUNC(SYSDATE), '${payment.card}', '${payment.auth}', '00', SYSDATE, SYSDATE, NULL,
'${payment.nsu}', ${payment.installments}, ${payment.amount}, '${payment.flagName}',
'${payment.paymentType}', SYSDATE, ${payment.userId} ) `;
await queryRunner.manager.query(sql);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
async createInvoice(data: CreateInvoiceDto): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const sql = `BEGIN
ESK_FATURAMENTO.FATURAMENTO_VENDA_ASSISTIDA(${data.orderId}, ${data.userId});
END;`;
await queryRunner.manager.query(sql);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} 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,
};
}

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