devsquad 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/devsquad.js +37 -11
- package/package.json +1 -1
- package/src/init.js +70 -26
- package/src/update.js +95 -0
- package/templates/.claude/skills/cicd/SKILL.md +46 -0
- package/templates/.claude/skills/cicd/backend-ci.md +114 -0
- package/templates/.claude/skills/cicd/frontend-ci.md +97 -0
- package/templates/.claude/skills/clickup/SKILL.md +63 -14
- package/templates/.claude/skills/docs/templates.md +203 -39
- package/templates/.claude/skills/figma/SKILL.md +125 -26
- package/templates/.claude/skills/nestjs/api-contracts.md +221 -0
- package/templates/.claude/skills/react/setup.md +61 -14
- package/templates/.claude/skills/testing/SKILL.md +78 -0
- package/templates/.claude/skills/testing/backend.md +161 -0
- package/templates/.claude/skills/testing/e2e.md +156 -0
- package/templates/.claude/skills/testing/frontend.md +138 -0
- package/templates/CLAUDE.md +7 -3
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# NestJS — Contratos de API (Formato padrão)
|
|
2
|
+
|
|
3
|
+
## Formato padrão de resposta
|
|
4
|
+
|
|
5
|
+
Todas as respostas da API seguem este contrato. Isso garante que o frontend possa tratar respostas de forma consistente.
|
|
6
|
+
|
|
7
|
+
### Sucesso
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// Resposta de sucesso — item único
|
|
11
|
+
{
|
|
12
|
+
"data": { "id": "uuid", "name": "João", "email": "joao@email.com" },
|
|
13
|
+
"meta": null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Resposta de sucesso — lista paginada
|
|
17
|
+
{
|
|
18
|
+
"data": [{ "id": "uuid", "name": "João" }],
|
|
19
|
+
"meta": {
|
|
20
|
+
"total": 100,
|
|
21
|
+
"page": 1,
|
|
22
|
+
"perPage": 20,
|
|
23
|
+
"totalPages": 5
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Resposta de criação (201)
|
|
28
|
+
{
|
|
29
|
+
"data": { "id": "uuid-novo" }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Resposta sem conteúdo (204) — sem body
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Erro
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
// Erro de validação (400)
|
|
39
|
+
{
|
|
40
|
+
"statusCode": 400,
|
|
41
|
+
"error": "Bad Request",
|
|
42
|
+
"message": ["email must be an email", "password is too weak"]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Erro de autenticação (401)
|
|
46
|
+
{
|
|
47
|
+
"statusCode": 401,
|
|
48
|
+
"error": "Unauthorized",
|
|
49
|
+
"message": "Credenciais inválidas"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Erro de permissão (403)
|
|
53
|
+
{
|
|
54
|
+
"statusCode": 403,
|
|
55
|
+
"error": "Forbidden",
|
|
56
|
+
"message": "Sem permissão para esta ação"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Não encontrado (404)
|
|
60
|
+
{
|
|
61
|
+
"statusCode": 404,
|
|
62
|
+
"error": "Not Found",
|
|
63
|
+
"message": "Usuário não encontrado"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Conflito (409)
|
|
67
|
+
{
|
|
68
|
+
"statusCode": 409,
|
|
69
|
+
"error": "Conflict",
|
|
70
|
+
"message": "E-mail já cadastrado"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Implementação — Response Interceptor
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// src/common/interceptors/response.interceptor.ts
|
|
78
|
+
import {
|
|
79
|
+
Injectable,
|
|
80
|
+
NestInterceptor,
|
|
81
|
+
ExecutionContext,
|
|
82
|
+
CallHandler,
|
|
83
|
+
} from '@nestjs/common';
|
|
84
|
+
import { Observable } from 'rxjs';
|
|
85
|
+
import { map } from 'rxjs/operators';
|
|
86
|
+
|
|
87
|
+
export interface ApiResponse<T> {
|
|
88
|
+
data: T;
|
|
89
|
+
meta: PaginationMeta | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface PaginationMeta {
|
|
93
|
+
total: number;
|
|
94
|
+
page: number;
|
|
95
|
+
perPage: number;
|
|
96
|
+
totalPages: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@Injectable()
|
|
100
|
+
export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
|
101
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
|
|
102
|
+
return next.handle().pipe(
|
|
103
|
+
map((value) => {
|
|
104
|
+
// Se já tem formato { data, meta } — retorna como está
|
|
105
|
+
if (value && 'data' in value) return value;
|
|
106
|
+
// Caso contrário, envolve em { data, meta: null }
|
|
107
|
+
return { data: value, meta: null };
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// src/main.ts — registrar globalmente
|
|
116
|
+
app.useGlobalInterceptors(new ResponseInterceptor());
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Implementação — Paginação
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
// src/common/dto/pagination.dto.ts
|
|
123
|
+
import { Type } from 'class-transformer';
|
|
124
|
+
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
|
125
|
+
|
|
126
|
+
export class PaginationDto {
|
|
127
|
+
@IsOptional()
|
|
128
|
+
@Type(() => Number)
|
|
129
|
+
@IsInt()
|
|
130
|
+
@Min(1)
|
|
131
|
+
page: number = 1;
|
|
132
|
+
|
|
133
|
+
@IsOptional()
|
|
134
|
+
@Type(() => Number)
|
|
135
|
+
@IsInt()
|
|
136
|
+
@Min(1)
|
|
137
|
+
@Max(100)
|
|
138
|
+
perPage: number = 20;
|
|
139
|
+
|
|
140
|
+
get skip(): number {
|
|
141
|
+
return (this.page - 1) * this.perPage;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// src/common/helpers/paginate.helper.ts
|
|
148
|
+
import { PaginationMeta } from '../interceptors/response.interceptor';
|
|
149
|
+
|
|
150
|
+
export function paginate<T>(
|
|
151
|
+
data: T[],
|
|
152
|
+
total: number,
|
|
153
|
+
page: number,
|
|
154
|
+
perPage: number,
|
|
155
|
+
): { data: T[]; meta: PaginationMeta } {
|
|
156
|
+
return {
|
|
157
|
+
data,
|
|
158
|
+
meta: {
|
|
159
|
+
total,
|
|
160
|
+
page,
|
|
161
|
+
perPage,
|
|
162
|
+
totalPages: Math.ceil(total / perPage),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Uso no Controller
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// Resposta paginada
|
|
172
|
+
@Get()
|
|
173
|
+
async findAll(@Query() pagination: PaginationDto) {
|
|
174
|
+
const [users, total] = await this.usersService.findAll(pagination);
|
|
175
|
+
return paginate(users, total, pagination.page, pagination.perPage);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Resposta simples (envolvida automaticamente pelo interceptor)
|
|
179
|
+
@Get(':id')
|
|
180
|
+
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
|
181
|
+
return this.usersService.findOne(id);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Criação
|
|
185
|
+
@Post()
|
|
186
|
+
@HttpCode(201)
|
|
187
|
+
async create(@Body() dto: CreateUserDto) {
|
|
188
|
+
return this.usersService.create(dto);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Sem conteúdo
|
|
192
|
+
@Delete(':id')
|
|
193
|
+
@HttpCode(204)
|
|
194
|
+
async remove(@Param('id', ParseUUIDPipe) id: string) {
|
|
195
|
+
await this.usersService.remove(id);
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Nunca retornar senha ou dados sensíveis
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
// ❌ ERRADO — senha no response
|
|
203
|
+
return user;
|
|
204
|
+
|
|
205
|
+
// ✅ CORRETO — excluir campos sensíveis
|
|
206
|
+
const { password, hashedRefreshToken, ...result } = user;
|
|
207
|
+
return result;
|
|
208
|
+
|
|
209
|
+
// ✅ ALTERNATIVA — usar @Exclude no Prisma result
|
|
210
|
+
// Configurar class-transformer no main.ts:
|
|
211
|
+
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Checklist de contratos de API
|
|
215
|
+
|
|
216
|
+
- [ ] Todos os endpoints retornam `{ data, meta }` via interceptor global
|
|
217
|
+
- [ ] Listas paginadas usam `PaginationDto` e `paginate()` helper
|
|
218
|
+
- [ ] Erros usam as exceções padrão do NestJS (nunca retornar erro genérico)
|
|
219
|
+
- [ ] `password` e `hashedRefreshToken` NUNCA no response
|
|
220
|
+
- [ ] Swagger configurado com exemplos de request/response
|
|
221
|
+
- [ ] IDs sempre UUID — nunca sequencial
|
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
Complementa o **SKILL.md** com arquivos base: API, auth e rotas.
|
|
4
4
|
|
|
5
|
+
## ⚠️ SEGURANÇA — Armazenamento de tokens (OWASP A02)
|
|
6
|
+
|
|
7
|
+
> **Nunca use `localStorage` para armazenar tokens JWT.**
|
|
8
|
+
> localStorage é acessível por qualquer JavaScript da página — vulnerável a XSS.
|
|
9
|
+
|
|
10
|
+
**Estratégia correta:**
|
|
11
|
+
- **Access token** → memória (variável de estado React, nunca persistida)
|
|
12
|
+
- **Refresh token** → cookie `httpOnly` (configurado pelo backend, inacessível via JS)
|
|
13
|
+
- `withCredentials: true` no Axios para enviar o cookie automaticamente
|
|
14
|
+
|
|
15
|
+
O backend deve configurar o refresh token como cookie `httpOnly; Secure; SameSite=Strict`.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
5
19
|
## src/services/api.ts
|
|
6
20
|
|
|
7
21
|
```typescript
|
|
@@ -9,22 +23,39 @@ import axios from 'axios';
|
|
|
9
23
|
|
|
10
24
|
const api = axios.create({
|
|
11
25
|
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:3000/api/v1',
|
|
12
|
-
withCredentials: true,
|
|
26
|
+
withCredentials: true, // envia cookie httpOnly com refresh token automaticamente
|
|
13
27
|
});
|
|
14
28
|
|
|
29
|
+
// O access token fica apenas em memória — injetado pelo AuthContext
|
|
30
|
+
let _accessToken: string | null = null;
|
|
31
|
+
|
|
32
|
+
export const setAccessToken = (token: string | null) => { _accessToken = token; };
|
|
33
|
+
|
|
15
34
|
api.interceptors.request.use((config) => {
|
|
16
|
-
|
|
17
|
-
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
35
|
+
if (_accessToken) config.headers.Authorization = `Bearer ${_accessToken}`;
|
|
18
36
|
return config;
|
|
19
37
|
});
|
|
20
38
|
|
|
21
39
|
api.interceptors.response.use(
|
|
22
40
|
(r) => r,
|
|
23
41
|
async (error) => {
|
|
24
|
-
|
|
25
|
-
if (error.response?.status === 401) {
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
const original = error.config;
|
|
43
|
+
if (error.response?.status === 401 && !original._retry) {
|
|
44
|
+
original._retry = true;
|
|
45
|
+
try {
|
|
46
|
+
// Tenta renovar via cookie httpOnly (sem enviar token no body)
|
|
47
|
+
const { data } = await axios.post(
|
|
48
|
+
`${import.meta.env.VITE_API_URL}/auth/refresh`,
|
|
49
|
+
{},
|
|
50
|
+
{ withCredentials: true },
|
|
51
|
+
);
|
|
52
|
+
setAccessToken(data.access_token);
|
|
53
|
+
original.headers.Authorization = `Bearer ${data.access_token}`;
|
|
54
|
+
return api(original);
|
|
55
|
+
} catch {
|
|
56
|
+
setAccessToken(null);
|
|
57
|
+
window.location.href = '/login';
|
|
58
|
+
}
|
|
28
59
|
}
|
|
29
60
|
return Promise.reject(error);
|
|
30
61
|
},
|
|
@@ -43,15 +74,15 @@ import {
|
|
|
43
74
|
useCallback,
|
|
44
75
|
type ReactNode,
|
|
45
76
|
} from 'react';
|
|
46
|
-
import api from '../services/api';
|
|
77
|
+
import api, { setAccessToken } from '../services/api';
|
|
47
78
|
|
|
48
|
-
type User = { id: string; email: string; name: string };
|
|
79
|
+
type User = { id: string; email: string; name: string; role: string };
|
|
49
80
|
|
|
50
81
|
type AuthContextValue = {
|
|
51
82
|
user: User | null;
|
|
52
83
|
loading: boolean;
|
|
53
84
|
login: (email: string, password: string) => Promise<void>;
|
|
54
|
-
logout: () => void
|
|
85
|
+
logout: () => Promise<void>;
|
|
55
86
|
};
|
|
56
87
|
|
|
57
88
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
@@ -64,16 +95,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
64
95
|
setLoading(true);
|
|
65
96
|
try {
|
|
66
97
|
const { data } = await api.post('/auth/login', { email, password });
|
|
67
|
-
localStorage
|
|
98
|
+
// Access token apenas em memória — NÃO salvar em localStorage
|
|
99
|
+
setAccessToken(data.access_token);
|
|
68
100
|
setUser(data.user);
|
|
101
|
+
// O refresh token é salvo como cookie httpOnly pelo backend automaticamente
|
|
69
102
|
} finally {
|
|
70
103
|
setLoading(false);
|
|
71
104
|
}
|
|
72
105
|
}, []);
|
|
73
106
|
|
|
74
|
-
const logout = useCallback(() => {
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
const logout = useCallback(async () => {
|
|
108
|
+
try {
|
|
109
|
+
await api.post('/auth/logout'); // invalida refresh token no banco + limpa cookie
|
|
110
|
+
} finally {
|
|
111
|
+
setAccessToken(null);
|
|
112
|
+
setUser(null);
|
|
113
|
+
}
|
|
77
114
|
}, []);
|
|
78
115
|
|
|
79
116
|
return (
|
|
@@ -150,4 +187,14 @@ createRoot(document.getElementById('root')!).render(
|
|
|
150
187
|
);
|
|
151
188
|
```
|
|
152
189
|
|
|
190
|
+
## Checklist de segurança de auth no frontend
|
|
191
|
+
|
|
192
|
+
- [ ] Access token NUNCA em localStorage ou sessionStorage
|
|
193
|
+
- [ ] Access token apenas em variável de módulo (memória)
|
|
194
|
+
- [ ] Refresh token configurado como cookie httpOnly pelo backend
|
|
195
|
+
- [ ] `withCredentials: true` no Axios
|
|
196
|
+
- [ ] Interceptor de 401 com retry automático via refresh
|
|
197
|
+
- [ ] Logout invalida token no backend + limpa estado local
|
|
198
|
+
- [ ] Não logar tokens em console.log
|
|
199
|
+
|
|
153
200
|
Substitua os placeholders de página pelos componentes reais em `pages/`.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: testing
|
|
3
|
+
description: Estratégia e implementação de testes para NestJS + React + React Native. Use quando o desenvolvedor precisar configurar testes unitários, de integração ou e2e, escrever specs, mockar dependências ou configurar cobertura de código.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
|
|
8
|
+
Stack: Jest (backend) · Vitest (frontend) · Supertest · Testing Library · Playwright (e2e)
|
|
9
|
+
|
|
10
|
+
## Pirâmide de testes adotada
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
/\
|
|
14
|
+
/e2e\ ← Playwright — fluxos críticos (login, checkout)
|
|
15
|
+
/------\
|
|
16
|
+
/integra-\ ← Supertest — endpoints reais com banco de teste
|
|
17
|
+
/ ção \
|
|
18
|
+
/------------\
|
|
19
|
+
/ unitários \ ← Jest/Vitest — lógica isolada, services, utils
|
|
20
|
+
/________________\
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
> Regra: mais unitários, menos e2e. E2e é caro — use apenas para fluxos críticos de negócio.
|
|
24
|
+
|
|
25
|
+
## Backend — Jest + Supertest
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Já vem com NestJS — verificar se está instalado
|
|
29
|
+
npm install --save-dev jest @types/jest ts-jest supertest @types/supertest
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// jest.config.ts
|
|
34
|
+
export default {
|
|
35
|
+
moduleFileExtensions: ['js', 'json', 'ts'],
|
|
36
|
+
rootDir: 'src',
|
|
37
|
+
testRegex: '.*\\.spec\\.ts$',
|
|
38
|
+
transform: { '^.+\\.(t|j)s$': 'ts-jest' },
|
|
39
|
+
collectCoverageFrom: ['**/*.(t|j)s'],
|
|
40
|
+
coverageDirectory: '../coverage',
|
|
41
|
+
testEnvironment: 'node',
|
|
42
|
+
};
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Frontend — Vitest + Testing Library
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install --save-dev vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// vite.config.ts — adicionar
|
|
53
|
+
test: {
|
|
54
|
+
globals: true,
|
|
55
|
+
environment: 'jsdom',
|
|
56
|
+
setupFiles: './src/test/setup.ts',
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// src/test/setup.ts
|
|
62
|
+
import '@testing-library/jest-dom';
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## E2E — Playwright
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install --save-dev @playwright/test
|
|
69
|
+
npx playwright install chromium
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Arquivos detalhados desta skill
|
|
73
|
+
|
|
74
|
+
- **backend.md** — Testes unitários e de integração para NestJS (services, guards, controllers)
|
|
75
|
+
- **frontend.md** — Testes de componentes React com Testing Library
|
|
76
|
+
- **e2e.md** — Fluxos e2e com Playwright
|
|
77
|
+
|
|
78
|
+
Leia os arquivos de detalhe para implementar cada camada da pirâmide.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Testing — Backend (NestJS + Jest)
|
|
2
|
+
|
|
3
|
+
## Teste unitário — Service
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
// src/auth/auth.service.spec.ts
|
|
7
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
8
|
+
import { AuthService } from './auth.service';
|
|
9
|
+
import { PrismaService } from '../prisma/prisma.service';
|
|
10
|
+
import { JwtService } from '@nestjs/jwt';
|
|
11
|
+
import { ConfigService } from '@nestjs/config';
|
|
12
|
+
import * as bcrypt from 'bcrypt';
|
|
13
|
+
|
|
14
|
+
const mockPrisma = {
|
|
15
|
+
user: {
|
|
16
|
+
findUnique: jest.fn(),
|
|
17
|
+
create: jest.fn(),
|
|
18
|
+
update: jest.fn(),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('AuthService', () => {
|
|
23
|
+
let service: AuthService;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
27
|
+
providers: [
|
|
28
|
+
AuthService,
|
|
29
|
+
{ provide: PrismaService, useValue: mockPrisma },
|
|
30
|
+
{ provide: JwtService, useValue: { signAsync: jest.fn().mockResolvedValue('token') } },
|
|
31
|
+
{ provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('secret') } },
|
|
32
|
+
],
|
|
33
|
+
}).compile();
|
|
34
|
+
|
|
35
|
+
service = module.get<AuthService>(AuthService);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => jest.clearAllMocks());
|
|
39
|
+
|
|
40
|
+
describe('login', () => {
|
|
41
|
+
it('deve retornar tokens com credenciais válidas', async () => {
|
|
42
|
+
const hashedPassword = await bcrypt.hash('senha123', 12);
|
|
43
|
+
mockPrisma.user.findUnique.mockResolvedValue({
|
|
44
|
+
id: 'uuid-1',
|
|
45
|
+
email: 'user@test.com',
|
|
46
|
+
password: hashedPassword,
|
|
47
|
+
role: 'USER',
|
|
48
|
+
});
|
|
49
|
+
mockPrisma.user.update.mockResolvedValue({});
|
|
50
|
+
|
|
51
|
+
const result = await service.login('user@test.com', 'senha123');
|
|
52
|
+
|
|
53
|
+
expect(result).toHaveProperty('access_token');
|
|
54
|
+
expect(result).toHaveProperty('refresh_token');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('deve lançar UnauthorizedException com credenciais inválidas', async () => {
|
|
58
|
+
mockPrisma.user.findUnique.mockResolvedValue(null);
|
|
59
|
+
|
|
60
|
+
await expect(service.login('x@x.com', 'errado')).rejects.toThrow(
|
|
61
|
+
'Credenciais inválidas',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Teste de integração — Controller com banco real
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
// test/auth.e2e-spec.ts
|
|
72
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
73
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
74
|
+
import * as request from 'supertest';
|
|
75
|
+
import { AppModule } from '../src/app.module';
|
|
76
|
+
import { PrismaService } from '../src/prisma/prisma.service';
|
|
77
|
+
|
|
78
|
+
describe('AuthController (integração)', () => {
|
|
79
|
+
let app: INestApplication;
|
|
80
|
+
let prisma: PrismaService;
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
84
|
+
imports: [AppModule],
|
|
85
|
+
}).compile();
|
|
86
|
+
|
|
87
|
+
app = module.createNestApplication();
|
|
88
|
+
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
|
89
|
+
await app.init();
|
|
90
|
+
|
|
91
|
+
prisma = module.get<PrismaService>(PrismaService);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterAll(async () => {
|
|
95
|
+
await prisma.$executeRaw`TRUNCATE TABLE users CASCADE`;
|
|
96
|
+
await app.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('POST /auth/register', () => {
|
|
100
|
+
it('deve registrar um novo usuário', () => {
|
|
101
|
+
return request(app.getHttpServer())
|
|
102
|
+
.post('/auth/register')
|
|
103
|
+
.send({ name: 'Teste', email: 'teste@email.com', password: 'Senha@123' })
|
|
104
|
+
.expect(201)
|
|
105
|
+
.expect((res) => {
|
|
106
|
+
expect(res.body.data).toHaveProperty('id');
|
|
107
|
+
expect(res.body.data).not.toHaveProperty('password');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('deve rejeitar email duplicado', () => {
|
|
112
|
+
return request(app.getHttpServer())
|
|
113
|
+
.post('/auth/register')
|
|
114
|
+
.send({ name: 'Teste', email: 'teste@email.com', password: 'Senha@123' })
|
|
115
|
+
.expect(409);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('POST /auth/login', () => {
|
|
120
|
+
it('deve retornar tokens com credenciais válidas', async () => {
|
|
121
|
+
const res = await request(app.getHttpServer())
|
|
122
|
+
.post('/auth/login')
|
|
123
|
+
.send({ email: 'teste@email.com', password: 'Senha@123' })
|
|
124
|
+
.expect(200);
|
|
125
|
+
|
|
126
|
+
expect(res.body.data).toHaveProperty('access_token');
|
|
127
|
+
// Refresh token deve vir como cookie httpOnly, não no body
|
|
128
|
+
expect(res.headers['set-cookie']).toBeDefined();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Banco de dados de teste
|
|
135
|
+
|
|
136
|
+
```env
|
|
137
|
+
# .env.test
|
|
138
|
+
DATABASE_URL="postgresql://user:pass@localhost:5432/myapp_test"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
// package.json
|
|
143
|
+
{
|
|
144
|
+
"scripts": {
|
|
145
|
+
"test": "jest",
|
|
146
|
+
"test:watch": "jest --watch",
|
|
147
|
+
"test:cov": "jest --coverage",
|
|
148
|
+
"test:e2e": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json",
|
|
149
|
+
"test:e2e:watch": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json --watch"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Checklist de testes backend
|
|
155
|
+
|
|
156
|
+
- [ ] Cada service tem `.spec.ts` com casos de sucesso e erro
|
|
157
|
+
- [ ] Banco de dados de teste separado (`.env.test`)
|
|
158
|
+
- [ ] Dados de teste limpos após cada suite (`afterAll` com TRUNCATE)
|
|
159
|
+
- [ ] Senhas nunca hardcoded nos testes — usar factory functions
|
|
160
|
+
- [ ] Cobertura mínima de 70% nos services
|
|
161
|
+
- [ ] Guards testados isoladamente
|