1125 lines
27 KiB
Markdown
1125 lines
27 KiB
Markdown
# Test Plan: MGN-002 Users
|
|
|
|
## Identificacion
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **ID** | TP-MGN002 |
|
|
| **Modulo** | MGN-002 Users |
|
|
| **Version** | 1.0 |
|
|
| **Fecha** | 2025-12-05 |
|
|
| **Autor** | System |
|
|
| **Estado** | Ready |
|
|
|
|
---
|
|
|
|
## Alcance
|
|
|
|
Este plan de pruebas cubre todos los flujos del modulo de gestion de usuarios:
|
|
|
|
| RF | Nombre | Prioridad |
|
|
|----|--------|-----------|
|
|
| RF-USER-001 | CRUD de Usuarios | P0 |
|
|
| RF-USER-002 | Perfil de Usuario | P1 |
|
|
| RF-USER-003 | Cambio de Email | P1 |
|
|
| RF-USER-004 | Cambio de Password | P0 |
|
|
| RF-USER-005 | Preferencias de Usuario | P2 |
|
|
|
|
---
|
|
|
|
## Estrategia de Pruebas
|
|
|
|
### Niveles de Testing
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ E2E Tests (10%) │
|
|
│ Cypress - Flujos completos de usuario │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ Integration Tests (30%) │
|
|
│ Supertest - API endpoints con base de datos real │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ Unit Tests (60%) │
|
|
│ Jest - Services, DTOs, validators, guards │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Cobertura Objetivo
|
|
|
|
| Tipo | Coverage Target |
|
|
|------|-----------------|
|
|
| Unit Tests | > 80% |
|
|
| Integration Tests | > 70% |
|
|
| E2E Tests | Flujos criticos |
|
|
|
|
---
|
|
|
|
## Test Cases
|
|
|
|
### TC-USR-001: Crear usuario como admin
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-001 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Precondiciones:**
|
|
- Admin autenticado con permiso `users:create`
|
|
- Rol valido existente
|
|
|
|
**Datos de entrada:**
|
|
```json
|
|
{
|
|
"email": "nuevo@empresa.com",
|
|
"firstName": "Juan",
|
|
"lastName": "Perez",
|
|
"phone": "+521234567890",
|
|
"roleIds": ["role-uuid"]
|
|
}
|
|
```
|
|
|
|
**Pasos:**
|
|
1. POST /api/v1/users con datos validos
|
|
2. Verificar response status 201
|
|
3. Verificar estructura del usuario retornado
|
|
4. Verificar que no incluye passwordHash
|
|
5. Verificar status = "pending_activation"
|
|
6. Verificar email de invitacion enviado
|
|
|
|
**Resultado esperado:**
|
|
```json
|
|
{
|
|
"id": "user-uuid",
|
|
"email": "nuevo@empresa.com",
|
|
"firstName": "Juan",
|
|
"lastName": "Perez",
|
|
"status": "pending_activation",
|
|
"createdAt": "2025-12-05T10:00:00Z",
|
|
"roles": [{ "id": "role-uuid", "name": "admin" }]
|
|
}
|
|
```
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('UsersController - Create', () => {
|
|
it('should create user with pending_activation status', async () => {
|
|
const createUserDto = {
|
|
email: 'nuevo@empresa.com',
|
|
firstName: 'Juan',
|
|
lastName: 'Perez',
|
|
roleIds: [testRole.id],
|
|
};
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/users')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send(createUserDto)
|
|
.expect(201);
|
|
|
|
expect(response.body).toMatchObject({
|
|
email: createUserDto.email,
|
|
firstName: createUserDto.firstName,
|
|
status: 'pending_activation',
|
|
});
|
|
expect(response.body.passwordHash).toBeUndefined();
|
|
expect(emailService.sendInvitation).toHaveBeenCalledWith(
|
|
createUserDto.email,
|
|
expect.any(String),
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-002: Listar usuarios con paginacion
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-001 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Precondiciones:**
|
|
- Admin autenticado con permiso `users:read`
|
|
- 50 usuarios en el tenant
|
|
|
|
**Datos de entrada:**
|
|
```
|
|
GET /api/v1/users?page=2&limit=10&sortBy=createdAt&sortOrder=DESC
|
|
```
|
|
|
|
**Resultado esperado:**
|
|
```json
|
|
{
|
|
"data": [...10 usuarios],
|
|
"meta": {
|
|
"total": 50,
|
|
"page": 2,
|
|
"limit": 10,
|
|
"totalPages": 5,
|
|
"hasNext": true,
|
|
"hasPrev": true
|
|
}
|
|
}
|
|
```
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('UsersController - List', () => {
|
|
beforeAll(async () => {
|
|
// Crear 50 usuarios de prueba
|
|
await Promise.all(
|
|
Array.from({ length: 50 }, (_, i) =>
|
|
userRepository.save({
|
|
email: `user${i}@test.com`,
|
|
firstName: `User${i}`,
|
|
lastName: 'Test',
|
|
tenantId: testTenant.id,
|
|
status: 'active',
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should return paginated users', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/users?page=2&limit=10')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(10);
|
|
expect(response.body.meta).toMatchObject({
|
|
total: 50,
|
|
page: 2,
|
|
limit: 10,
|
|
totalPages: 5,
|
|
hasNext: true,
|
|
hasPrev: true,
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-003: Buscar usuarios por texto
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-001 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P1 |
|
|
|
|
**Precondiciones:**
|
|
- Usuarios: "Juan Perez", "Juana Garcia", "Pedro Lopez"
|
|
|
|
**Datos de entrada:**
|
|
```
|
|
GET /api/v1/users?search=juan
|
|
```
|
|
|
|
**Resultado esperado:**
|
|
- Retorna "Juan Perez" y "Juana Garcia"
|
|
- No retorna "Pedro Lopez"
|
|
- Busqueda case-insensitive
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('UsersController - Search', () => {
|
|
beforeAll(async () => {
|
|
await userRepository.save([
|
|
{ email: 'juan@test.com', firstName: 'Juan', lastName: 'Perez', ...baseUser },
|
|
{ email: 'juana@test.com', firstName: 'Juana', lastName: 'Garcia', ...baseUser },
|
|
{ email: 'pedro@test.com', firstName: 'Pedro', lastName: 'Lopez', ...baseUser },
|
|
]);
|
|
});
|
|
|
|
it('should search users by name', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/users?search=juan')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(2);
|
|
expect(response.body.data.map(u => u.firstName)).toEqual(
|
|
expect.arrayContaining(['Juan', 'Juana']),
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-004: Soft delete de usuario
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-001 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Precondiciones:**
|
|
- Admin autenticado con permiso `users:delete`
|
|
- Usuario activo existente
|
|
|
|
**Pasos:**
|
|
1. DELETE /api/v1/users/:id
|
|
2. Verificar response 200
|
|
3. Verificar deleted_at establecido
|
|
4. Verificar deleted_by = admin_id
|
|
5. Verificar usuario no aparece en listados
|
|
6. Verificar usuario no puede hacer login
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('UsersController - Delete', () => {
|
|
it('should soft delete user', async () => {
|
|
const userToDelete = await userRepository.save({
|
|
email: 'todelete@test.com',
|
|
firstName: 'ToDelete',
|
|
lastName: 'User',
|
|
tenantId: testTenant.id,
|
|
status: 'active',
|
|
});
|
|
|
|
await request(app.getHttpServer())
|
|
.delete(`/api/v1/users/${userToDelete.id}`)
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.expect(200);
|
|
|
|
// Verificar soft delete
|
|
const deletedUser = await userRepository.findOne({
|
|
where: { id: userToDelete.id },
|
|
withDeleted: true,
|
|
});
|
|
expect(deletedUser.deletedAt).not.toBeNull();
|
|
expect(deletedUser.deletedBy).toBe(adminUser.id);
|
|
|
|
// Verificar no aparece en listados
|
|
const listResponse = await request(app.getHttpServer())
|
|
.get('/api/v1/users')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
const ids = listResponse.body.data.map(u => u.id);
|
|
expect(ids).not.toContain(userToDelete.id);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-005: No puede eliminarse a si mismo
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-001 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Precondiciones:**
|
|
- Admin autenticado
|
|
|
|
**Pasos:**
|
|
1. DELETE /api/v1/users/:ownId
|
|
2. Verificar response 400
|
|
3. Verificar mensaje de error
|
|
|
|
**Resultado esperado:**
|
|
```json
|
|
{
|
|
"statusCode": 400,
|
|
"message": "No puedes eliminarte a ti mismo"
|
|
}
|
|
```
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
it('should not allow self-deletion', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.delete(`/api/v1/users/${adminUser.id}`)
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.expect(400);
|
|
|
|
expect(response.body.message).toBe('No puedes eliminarte a ti mismo');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-006: Ver perfil propio
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-002 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Precondiciones:**
|
|
- Usuario autenticado
|
|
|
|
**Pasos:**
|
|
1. GET /api/v1/users/me
|
|
2. Verificar response 200
|
|
3. Verificar datos del perfil
|
|
4. Verificar NO incluye passwordHash
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('ProfileController - Get', () => {
|
|
it('should return own profile', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/users/me')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
id: testUser.id,
|
|
email: testUser.email,
|
|
firstName: testUser.firstName,
|
|
lastName: testUser.lastName,
|
|
});
|
|
expect(response.body.passwordHash).toBeUndefined();
|
|
expect(response.body.roles).toBeDefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-007: Actualizar perfil propio
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-002 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P1 |
|
|
|
|
**Datos de entrada:**
|
|
```json
|
|
{
|
|
"firstName": "Carlos",
|
|
"lastName": "Lopez",
|
|
"phone": "+521234567890"
|
|
}
|
|
```
|
|
|
|
**Resultado esperado:**
|
|
- Perfil actualizado
|
|
- Email NO puede cambiarse por este endpoint
|
|
- Campos no incluidos permanecen igual
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
it('should update own profile', async () => {
|
|
const updateDto = {
|
|
firstName: 'Carlos',
|
|
lastName: 'Lopez',
|
|
phone: '+521234567890',
|
|
};
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.patch('/api/v1/users/me')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.send(updateDto)
|
|
.expect(200);
|
|
|
|
expect(response.body.firstName).toBe('Carlos');
|
|
expect(response.body.lastName).toBe('Lopez');
|
|
expect(response.body.email).toBe(testUser.email); // No cambio
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-008: Subir avatar
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-002 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P1 |
|
|
|
|
**Precondiciones:**
|
|
- Usuario autenticado
|
|
- Imagen JPG de 2MB
|
|
|
|
**Pasos:**
|
|
1. POST /api/v1/users/me/avatar con imagen
|
|
2. Verificar response 200
|
|
3. Verificar avatarUrl y avatarThumbnailUrl retornados
|
|
4. Verificar imagen redimensionada a 200x200
|
|
5. Verificar thumbnail 50x50
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('ProfileController - Avatar', () => {
|
|
it('should upload and resize avatar', async () => {
|
|
const imagePath = path.join(__dirname, 'fixtures', 'test-avatar.jpg');
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/users/me/avatar')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.attach('avatar', imagePath)
|
|
.expect(200);
|
|
|
|
expect(response.body.avatarUrl).toContain('avatar-200');
|
|
expect(response.body.avatarThumbnailUrl).toContain('avatar-50');
|
|
|
|
// Verificar dimensiones si es posible
|
|
// const metadata = await sharp(response.body.avatarUrl).metadata();
|
|
// expect(metadata.width).toBe(200);
|
|
// expect(metadata.height).toBe(200);
|
|
});
|
|
|
|
it('should reject avatar > 10MB', async () => {
|
|
const largePath = path.join(__dirname, 'fixtures', 'large-image.jpg');
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/users/me/avatar')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.attach('avatar', largePath)
|
|
.expect(400);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-009: Solicitar cambio de email
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-003 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P1 |
|
|
|
|
**Datos de entrada:**
|
|
```json
|
|
{
|
|
"newEmail": "nuevo@empresa.com",
|
|
"currentPassword": "Password123!"
|
|
}
|
|
```
|
|
|
|
**Pasos:**
|
|
1. POST /api/v1/users/me/email/request
|
|
2. Verificar response 200
|
|
3. Verificar email de verificacion enviado al nuevo email
|
|
4. Verificar email actual NO cambia aun
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('EmailChangeController', () => {
|
|
it('should request email change', async () => {
|
|
const dto = {
|
|
newEmail: 'nuevo@empresa.com',
|
|
currentPassword: 'Password123!',
|
|
};
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/users/me/email/request')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.send(dto)
|
|
.expect(200);
|
|
|
|
expect(response.body.message).toContain('verificacion');
|
|
|
|
// Email actual sin cambio
|
|
const user = await userRepository.findOne({ where: { id: testUser.id } });
|
|
expect(user.email).toBe(testUser.email);
|
|
|
|
// Email de verificacion enviado
|
|
expect(emailService.sendEmailVerification).toHaveBeenCalledWith(
|
|
dto.newEmail,
|
|
expect.any(String),
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-010: Confirmar cambio de email
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-003 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P1 |
|
|
|
|
**Precondiciones:**
|
|
- Solicitud de cambio pendiente con token valido
|
|
|
|
**Pasos:**
|
|
1. POST /api/v1/users/me/email/confirm con token
|
|
2. Verificar email actualizado
|
|
3. Verificar token consumido (no reutilizable)
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
it('should confirm email change with valid token', async () => {
|
|
// Crear solicitud pendiente
|
|
const changeRequest = await emailChangeRepository.save({
|
|
userId: testUser.id,
|
|
newEmail: 'nuevo@empresa.com',
|
|
token: 'valid-token-hash',
|
|
expiresAt: new Date(Date.now() + 3600000),
|
|
});
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/users/me/email/confirm')
|
|
.send({ token: 'valid-token' })
|
|
.expect(200);
|
|
|
|
const updatedUser = await userRepository.findOne({ where: { id: testUser.id } });
|
|
expect(updatedUser.email).toBe('nuevo@empresa.com');
|
|
|
|
// Token no reutilizable
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/users/me/email/confirm')
|
|
.send({ token: 'valid-token' })
|
|
.expect(400);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-011: Cambiar password exitosamente
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-004 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Datos de entrada:**
|
|
```json
|
|
{
|
|
"currentPassword": "OldPass123!",
|
|
"newPassword": "NewPass456!",
|
|
"confirmPassword": "NewPass456!",
|
|
"logoutOtherSessions": true
|
|
}
|
|
```
|
|
|
|
**Pasos:**
|
|
1. POST /api/v1/users/me/password
|
|
2. Verificar response 200
|
|
3. Verificar password actualizado (login funciona con nuevo)
|
|
4. Verificar password guardado en historial
|
|
5. Verificar otras sesiones invalidadas
|
|
6. Verificar email de notificacion enviado
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('PasswordController', () => {
|
|
it('should change password and logout other sessions', async () => {
|
|
const dto = {
|
|
currentPassword: 'OldPass123!',
|
|
newPassword: 'NewPass456!',
|
|
confirmPassword: 'NewPass456!',
|
|
logoutOtherSessions: true,
|
|
};
|
|
|
|
// Crear sesiones adicionales
|
|
await sessionService.createSession(testUser.id, 'device-1');
|
|
await sessionService.createSession(testUser.id, 'device-2');
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/users/me/password')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.send(dto)
|
|
.expect(200);
|
|
|
|
expect(response.body.sessionsInvalidated).toBe(2);
|
|
|
|
// Verificar login con nuevo password
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/login')
|
|
.send({ email: testUser.email, password: dto.newPassword })
|
|
.expect(200);
|
|
|
|
// Verificar historial
|
|
const history = await passwordHistoryRepository.find({
|
|
where: { userId: testUser.id },
|
|
});
|
|
expect(history.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-012: Password no cumple requisitos
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-004 |
|
|
| **Tipo** | Unit |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Datos de entrada:**
|
|
```json
|
|
{
|
|
"currentPassword": "OldPass123!",
|
|
"newPassword": "abc123",
|
|
"confirmPassword": "abc123"
|
|
}
|
|
```
|
|
|
|
**Resultado esperado:**
|
|
```json
|
|
{
|
|
"statusCode": 400,
|
|
"message": "El password no cumple los requisitos",
|
|
"errors": [
|
|
"Debe tener al menos 8 caracteres",
|
|
"Debe incluir una mayuscula",
|
|
"Debe incluir caracter especial"
|
|
]
|
|
}
|
|
```
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('PasswordValidator', () => {
|
|
const validator = new PasswordPolicyValidator();
|
|
|
|
it('should reject weak password', () => {
|
|
const result = validator.validate('abc123');
|
|
|
|
expect(result.isValid).toBe(false);
|
|
expect(result.errors).toContain('Debe tener al menos 8 caracteres');
|
|
expect(result.errors).toContain('Debe incluir una mayuscula');
|
|
expect(result.errors).toContain('Debe incluir caracter especial');
|
|
});
|
|
|
|
it('should accept strong password', () => {
|
|
const result = validator.validate('NewPass456!');
|
|
expect(result.isValid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-013: No reutilizar password anterior
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-004 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Precondiciones:**
|
|
- Usuario uso "MiPass123!" hace 2 meses (en historial)
|
|
|
|
**Datos de entrada:**
|
|
```json
|
|
{
|
|
"currentPassword": "CurrentPass!",
|
|
"newPassword": "MiPass123!",
|
|
"confirmPassword": "MiPass123!"
|
|
}
|
|
```
|
|
|
|
**Resultado esperado:**
|
|
```json
|
|
{
|
|
"statusCode": 400,
|
|
"message": "No puedes usar un password que hayas usado anteriormente"
|
|
}
|
|
```
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
it('should not allow reusing recent passwords', async () => {
|
|
// Agregar password al historial
|
|
await passwordHistoryRepository.save({
|
|
userId: testUser.id,
|
|
passwordHash: await bcrypt.hash('MiPass123!', 12),
|
|
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), // 60 dias atras
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/users/me/password')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.send({
|
|
currentPassword: 'CurrentPass!',
|
|
newPassword: 'MiPass123!',
|
|
confirmPassword: 'MiPass123!',
|
|
})
|
|
.expect(400);
|
|
|
|
expect(response.body.message).toContain('usado anteriormente');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-014: Obtener preferencias
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-005 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P1 |
|
|
|
|
**Precondiciones:**
|
|
- Usuario autenticado
|
|
- Sin preferencias personalizadas
|
|
|
|
**Resultado esperado:**
|
|
- Retorna defaults del tenant si no hay preferencias
|
|
- Estructura completa de preferencias
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('PreferencesController', () => {
|
|
it('should return tenant defaults if no preferences', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/users/me/preferences')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
language: 'es',
|
|
timezone: 'America/Mexico_City',
|
|
theme: 'light',
|
|
notifications: {
|
|
email: { enabled: true },
|
|
push: { enabled: true },
|
|
inApp: { enabled: true },
|
|
},
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-015: Actualizar preferencias parcialmente
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-005 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P1 |
|
|
|
|
**Datos de entrada:**
|
|
```json
|
|
{
|
|
"theme": "dark",
|
|
"notifications": {
|
|
"email": { "marketing": false }
|
|
}
|
|
}
|
|
```
|
|
|
|
**Resultado esperado:**
|
|
- Solo campos enviados se actualizan
|
|
- Deep merge para objetos anidados
|
|
- Otros campos mantienen valor anterior
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
it('should partially update preferences with deep merge', async () => {
|
|
// Establecer preferencias iniciales
|
|
await preferencesRepository.save({
|
|
userId: testUser.id,
|
|
language: 'es',
|
|
theme: 'light',
|
|
notifications: {
|
|
email: { enabled: true, marketing: true },
|
|
push: { enabled: true },
|
|
},
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.patch('/api/v1/users/me/preferences')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.send({
|
|
theme: 'dark',
|
|
notifications: {
|
|
email: { marketing: false },
|
|
},
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body.theme).toBe('dark');
|
|
expect(response.body.language).toBe('es'); // Sin cambio
|
|
expect(response.body.notifications.email.enabled).toBe(true); // Sin cambio
|
|
expect(response.body.notifications.email.marketing).toBe(false); // Cambiado
|
|
expect(response.body.notifications.push.enabled).toBe(true); // Sin cambio
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-016: Reset preferencias a defaults
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | RF-USER-005 |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P2 |
|
|
|
|
**Precondiciones:**
|
|
- Usuario con preferencias personalizadas
|
|
|
|
**Pasos:**
|
|
1. POST /api/v1/users/me/preferences/reset
|
|
2. Verificar preferencias vuelven a defaults del tenant
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
it('should reset preferences to tenant defaults', async () => {
|
|
// Preferencias personalizadas
|
|
await preferencesRepository.save({
|
|
userId: testUser.id,
|
|
language: 'en',
|
|
theme: 'dark',
|
|
timezone: 'America/New_York',
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/users/me/preferences/reset')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.expect(200);
|
|
|
|
// Deberia tener defaults del tenant
|
|
expect(response.body.language).toBe('es');
|
|
expect(response.body.theme).toBe('light');
|
|
expect(response.body.timezone).toBe('America/Mexico_City');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-017: Aislamiento multi-tenant
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | Transversal |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Precondiciones:**
|
|
- Tenant A con usuarios
|
|
- Tenant B con usuarios
|
|
- Admin de Tenant A autenticado
|
|
|
|
**Pasos:**
|
|
1. GET /api/v1/users (Tenant A)
|
|
2. Verificar solo retorna usuarios de Tenant A
|
|
3. Verificar no retorna usuarios de Tenant B
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('Multi-tenant Isolation', () => {
|
|
let tenantA, tenantB;
|
|
let adminTokenA, adminTokenB;
|
|
|
|
beforeAll(async () => {
|
|
tenantA = await tenantRepository.save({ name: 'Tenant A', slug: 'tenant-a' });
|
|
tenantB = await tenantRepository.save({ name: 'Tenant B', slug: 'tenant-b' });
|
|
|
|
// Crear usuarios en cada tenant
|
|
await userRepository.save([
|
|
{ email: 'user-a@test.com', tenantId: tenantA.id, ...baseUser },
|
|
{ email: 'user-b@test.com', tenantId: tenantB.id, ...baseUser },
|
|
]);
|
|
});
|
|
|
|
it('should only return users from same tenant', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/users')
|
|
.set('Authorization', `Bearer ${adminTokenA}`)
|
|
.expect(200);
|
|
|
|
const emails = response.body.data.map(u => u.email);
|
|
expect(emails).toContain('user-a@test.com');
|
|
expect(emails).not.toContain('user-b@test.com');
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### TC-USR-018: Control de acceso RBAC
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **RF** | Transversal |
|
|
| **Tipo** | Integration |
|
|
| **Prioridad** | P0 |
|
|
|
|
**Escenarios:**
|
|
1. Usuario sin permiso `users:create` no puede crear usuarios
|
|
2. Usuario sin permiso `users:delete` no puede eliminar usuarios
|
|
3. Usuario puede ver/editar su propio perfil sin permisos especiales
|
|
|
|
**Implementacion Jest:**
|
|
```typescript
|
|
describe('RBAC - Users', () => {
|
|
it('should deny user creation without users:create permission', async () => {
|
|
// Token de usuario sin permisos de admin
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/users')
|
|
.set('Authorization', `Bearer ${regularUserToken}`)
|
|
.send(createUserDto)
|
|
.expect(403);
|
|
});
|
|
|
|
it('should allow own profile access without special permissions', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/users/me')
|
|
.set('Authorization', `Bearer ${regularUserToken}`)
|
|
.expect(200);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Datos de Prueba
|
|
|
|
### Usuarios de Prueba
|
|
|
|
```typescript
|
|
const testUsers = {
|
|
admin: {
|
|
email: 'admin@test.com',
|
|
password: 'AdminPass123!',
|
|
firstName: 'Admin',
|
|
lastName: 'User',
|
|
roles: ['admin'],
|
|
permissions: ['users:create', 'users:read', 'users:update', 'users:delete'],
|
|
},
|
|
regular: {
|
|
email: 'user@test.com',
|
|
password: 'UserPass123!',
|
|
firstName: 'Regular',
|
|
lastName: 'User',
|
|
roles: ['user'],
|
|
permissions: [],
|
|
},
|
|
manager: {
|
|
email: 'manager@test.com',
|
|
password: 'ManagerPass123!',
|
|
firstName: 'Manager',
|
|
lastName: 'User',
|
|
roles: ['manager'],
|
|
permissions: ['users:read'],
|
|
},
|
|
};
|
|
```
|
|
|
|
### Imagenes de Prueba
|
|
|
|
```
|
|
test/fixtures/
|
|
├── test-avatar.jpg # 500KB, 800x800px
|
|
├── large-image.jpg # 15MB, excede limite
|
|
├── invalid-format.gif # Formato no soportado
|
|
└── small-avatar.png # 50KB, 100x100px
|
|
```
|
|
|
|
---
|
|
|
|
## Setup y Teardown
|
|
|
|
```typescript
|
|
// test/setup.ts
|
|
beforeAll(async () => {
|
|
// Crear base de datos de prueba
|
|
await createDatabase('erp_test');
|
|
|
|
// Ejecutar migraciones
|
|
await runMigrations();
|
|
|
|
// Crear tenant de prueba
|
|
testTenant = await createTestTenant();
|
|
|
|
// Crear usuarios de prueba
|
|
adminUser = await createTestUser('admin');
|
|
regularUser = await createTestUser('regular');
|
|
|
|
// Generar tokens
|
|
adminToken = await generateToken(adminUser);
|
|
userToken = await generateToken(regularUser);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Limpiar base de datos
|
|
await cleanDatabase();
|
|
|
|
// Cerrar conexiones
|
|
await closeConnections();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Limpiar tablas entre tests
|
|
await truncateTables([
|
|
'core_users.user_avatars',
|
|
'core_users.user_preferences',
|
|
'core_users.email_change_requests',
|
|
]);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Matriz de Trazabilidad
|
|
|
|
| Test Case | RF | User Story | Criterio Aceptacion |
|
|
|-----------|-------|------------|---------------------|
|
|
| TC-USR-001 | RF-USER-001 | US-MGN002-001 | Escenario 1 |
|
|
| TC-USR-002 | RF-USER-001 | US-MGN002-001 | Escenario 2 |
|
|
| TC-USR-003 | RF-USER-001 | US-MGN002-001 | Escenario 3 |
|
|
| TC-USR-004 | RF-USER-001 | US-MGN002-001 | Escenario 4 |
|
|
| TC-USR-005 | RF-USER-001 | US-MGN002-001 | Escenario 5 |
|
|
| TC-USR-006 | RF-USER-002 | US-MGN002-002 | Escenario 1 |
|
|
| TC-USR-007 | RF-USER-002 | US-MGN002-002 | Escenario 2 |
|
|
| TC-USR-008 | RF-USER-002 | US-MGN002-002 | Escenario 3 |
|
|
| TC-USR-009 | RF-USER-003 | - | Escenario 1 |
|
|
| TC-USR-010 | RF-USER-003 | - | Escenario 2 |
|
|
| TC-USR-011 | RF-USER-004 | US-MGN002-003 | Escenario 1 |
|
|
| TC-USR-012 | RF-USER-004 | US-MGN002-003 | Escenario 3 |
|
|
| TC-USR-013 | RF-USER-004 | US-MGN002-003 | Escenario 4 |
|
|
| TC-USR-014 | RF-USER-005 | US-MGN002-004 | Escenario 1 |
|
|
| TC-USR-015 | RF-USER-005 | US-MGN002-004 | Escenario 2-4 |
|
|
| TC-USR-016 | RF-USER-005 | US-MGN002-004 | Escenario 5 |
|
|
| TC-USR-017 | Transversal | - | Multi-tenancy |
|
|
| TC-USR-018 | Transversal | - | RBAC |
|
|
|
|
---
|
|
|
|
## Metricas de Calidad
|
|
|
|
### Criterios de Exito
|
|
|
|
| Metrica | Objetivo |
|
|
|---------|----------|
|
|
| Tests pasando | 100% |
|
|
| Code coverage | > 80% |
|
|
| Bugs criticos | 0 |
|
|
| Bugs mayores | < 2 |
|
|
|
|
### Defectos Conocidos
|
|
|
|
| ID | Descripcion | Severidad | Estado |
|
|
|----|-------------|-----------|--------|
|
|
| - | - | - | - |
|
|
|
|
---
|
|
|
|
## Historial
|
|
|
|
| Version | Fecha | Autor | Cambios |
|
|
|---------|-------|-------|---------|
|
|
| 1.0 | 2025-12-05 | System | Creacion inicial con 18 test cases |
|