7b/ Revue des Tests — PD-32¶
Tu es QA Engineer senior, spécialisé dans les tests automatisés NestJS/Jest.
TÂCHE¶
Produis une revue des tests pour l'implémentation PD-32 (endpoints GET/PUT /user/profile).
Évalue : - Couverture des cas nominaux et d'erreur - Pertinence des assertions - Robustesse (edge cases, données limites) - Isolation et indépendance des tests
Verdict : ✅ CONFORME / ⚠️ RÉSERVES / ❌ NON_CONFORME
AXES D'ANALYSE¶
- Tous les TC-* de la matrice sont couverts
- Assertions pertinentes (pas de
toBeTruthy()générique) - Tests isolés (pas de dépendance inter-tests)
- Mocking approprié (services, repositories)
- Edge cases couverts (null, vide, limites)
- Tests négatifs (erreurs attendues)
- Nommage descriptif des tests
TESTS CONTRACTUELS (référence)¶
Matrice de couverture¶
| ID Invariant | ID Critère | ID Test | Couverture | Commentaire |
|---|---|---|---|---|
| INV-32-01 | CA-32-01 | TC-ERR-01 | Oui | GET sans JWT refusé |
| INV-32-01 | CA-32-02 | TC-ERR-02 | Oui | PUT sans JWT refusé |
| INV-32-02 | CA-32-03 | TC-ERR-03 | Oui | Accès croisé lecture refusé |
| INV-32-02 | CA-32-03 | TC-ERR-04 | Oui | Accès croisé écriture refusé |
| INV-32-03 | CA-32-04 | TC-NOM-01 | Oui | Réponse profil non sensible |
| INV-32-04 | CA-32-04 | TC-INV-01 | Oui | Champs sensibles/protégés absents |
| INV-32-05 | CA-32-06 | TC-NOM-02 | Oui | Mise à jour name autorisée |
| INV-32-05 | CA-32-07 | TC-NOM-03 | Oui | Mise à jour avatar_url autorisée |
| INV-32-05 | CA-32-08 | TC-NOM-04 | Oui | Mise à jour preferences autorisée |
| INV-32-06 | CA-32-10 | TC-ERR-06 | Oui | Rejet modification email/plan |
| INV-32-06 | CA-32-11 | TC-ERR-07 | Oui | Rejet modification id/status/srpSalt/passwordHash |
| INV-32-07 | CA-32-09 | TC-ERR-05 | Oui | Rejet clé inconnue dans preferences |
| INV-32-08 | CA-32-05 | TC-NOM-01 | Oui | Contrat PD-106 respecté |
| INV-32-09 | CA-32-12 | TC-ERR-08 | Oui | Rate limit global appliqué |
| INV-32-10 | CA-32-13 | TC-INV-02 | Oui | Erreurs explicites sans mutation partielle |
| INV-32-10 | CA-32-13 | TC-ERR-09 | Oui | Validation invalide : échec explicite |
| INV-32-10 | CA-32-13 | TC-ERR-10 | Oui | Erreur interne : échec explicite |
TESTS IMPLÉMENTÉS À REVIEWER¶
update-user-profile.dto.spec.ts (Tests unitaires DTO)¶
import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { UpdateUserProfileDto } from './update-user-profile.dto';
describe('UpdateUserProfileDto', () => {
async function validateDto(dto: object): Promise<ValidationError[]> {
const instance = plainToInstance(UpdateUserProfileDto, dto);
return validate(instance, { whitelist: true, forbidNonWhitelisted: true });
}
function hasPropertyError(errors: ValidationError[], property: string): boolean {
return errors.some((e) => e.property === property);
}
describe('PD-32 Valid payloads', () => {
it('should accept valid update with all fields', async () => {
const dto = {
name: 'Jean Dupont',
avatarUrl: 'https://example.com/avatar.png',
preferences: {
locale: 'fr',
timezone: 'Europe/Paris',
},
};
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
it('should accept update with only name', async () => {
const dto = { name: 'Jean Dupont' };
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
it('should accept update with only avatarUrl', async () => {
const dto = { avatarUrl: 'https://cdn.example.com/images/user123.jpg' };
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
it('should accept update with only preferences', async () => {
const dto = {
preferences: {
locale: 'en',
notifications: { email: true },
},
};
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
it('should accept empty object (no changes)', async () => {
const dto = {};
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
});
describe('INV-32-05: Validates name field', () => {
it('should accept valid name', async () => {
const dto = { name: 'John Doe' };
const errors = await validateDto(dto);
expect(hasPropertyError(errors, 'name')).toBe(false);
});
it('should reject name exceeding 100 characters', async () => {
const dto = { name: 'a'.repeat(101) };
const errors = await validateDto(dto);
expect(hasPropertyError(errors, 'name')).toBe(true);
});
it('should accept name with exactly 100 characters', async () => {
const dto = { name: 'a'.repeat(100) };
const errors = await validateDto(dto);
expect(hasPropertyError(errors, 'name')).toBe(false);
});
it('should reject name as number', async () => {
const dto = { name: 12345 };
const errors = await validateDto(dto);
expect(hasPropertyError(errors, 'name')).toBe(true);
});
});
describe('INV-32-05: Validates avatarUrl field', () => {
it('should accept valid HTTPS URL', async () => {
const dto = { avatarUrl: 'https://example.com/avatar.png' };
const errors = await validateDto(dto);
expect(hasPropertyError(errors, 'avatarUrl')).toBe(false);
});
it('should reject HTTP URL (not HTTPS)', async () => {
const dto = { avatarUrl: 'http://example.com/avatar.png' };
const errors = await validateDto(dto);
expect(hasPropertyError(errors, 'avatarUrl')).toBe(true);
});
it('should reject URL without protocol', async () => {
const dto = { avatarUrl: 'example.com/avatar.png' };
const errors = await validateDto(dto);
expect(hasPropertyError(errors, 'avatarUrl')).toBe(true);
});
it('should reject invalid URL', async () => {
const dto = { avatarUrl: 'not-a-url' };
const errors = await validateDto(dto);
expect(hasPropertyError(errors, 'avatarUrl')).toBe(true);
});
it('should reject avatarUrl exceeding 2048 characters', async () => {
const longUrl = 'https://example.com/' + 'a'.repeat(2030);
const dto = { avatarUrl: longUrl };
const errors = await validateDto(dto);
expect(hasPropertyError(errors, 'avatarUrl')).toBe(true);
});
});
describe('INV-32-06: Rejects protected fields', () => {
it('should reject email field (protected)', async () => {
const dto = { name: 'John', email: 'new@email.com' };
const errors = await validateDto(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('should reject plan field (protected)', async () => {
const dto = { name: 'John', plan: 'premium' };
const errors = await validateDto(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('should reject id field (protected)', async () => {
const dto = { name: 'John', id: '550e8400-e29b-41d4-a716-446655440000' };
const errors = await validateDto(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('should reject status field (protected)', async () => {
const dto = { name: 'John', status: 'ACTIVE' };
const errors = await validateDto(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('should reject passwordHash field (sensitive)', async () => {
const dto = { name: 'John', passwordHash: 'somehash' };
const errors = await validateDto(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('should reject srpSalt field (sensitive)', async () => {
const dto = { name: 'John', srpSalt: 'somesalt' };
const errors = await validateDto(dto);
expect(errors.length).toBeGreaterThan(0);
});
});
describe('INV-32-07: Validates preferences structure', () => {
it('should accept valid preferences with locale', async () => {
const dto = { preferences: { locale: 'fr' } };
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
it('should accept valid preferences with timezone', async () => {
const dto = { preferences: { timezone: 'Europe/Paris' } };
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
it('should accept valid preferences with security settings', async () => {
const dto = {
preferences: {
security: { loginNotifications: true, sessionTimeout: 30 },
},
};
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
it('should accept valid preferences with notifications', async () => {
const dto = {
preferences: { notifications: { email: true, push: false } },
};
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
it('should reject invalid locale value', async () => {
const dto = { preferences: { locale: 'invalid' } };
const errors = await validateDto(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('should accept locale "en"', async () => {
const dto = { preferences: { locale: 'en' } };
const errors = await validateDto(dto);
expect(errors).toHaveLength(0);
});
});
});
user-profile.service.spec.ts (Tests unitaires Service)¶
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { RlsQueryService } from '../../../database/services/rls-query.service';
import { User } from '../../auth/entities/user.entity';
import { UserProfileService } from './user-profile.service';
import { UpdateUserProfileDto } from '../dto/update-user-profile.dto';
describe('UserProfileService', () => {
let service: UserProfileService;
let rlsQueryService: jest.Mocked<RlsQueryService>;
let dataSource: jest.Mocked<DataSource>;
let entityManager: jest.Mocked<EntityManager>;
const mockUserId = '550e8400-e29b-41d4-a716-446655440000';
const mockUser: Partial<User> = {
id: mockUserId,
email: 'test@example.com',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.png',
preferences: { locale: 'fr', timezone: 'Europe/Paris' },
};
beforeEach(async () => {
rlsQueryService = {
findOneWithRls: jest.fn(),
} as unknown as jest.Mocked<RlsQueryService>;
entityManager = {
query: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
} as unknown as jest.Mocked<EntityManager>;
dataSource = {
transaction: jest.fn((callback) => callback(entityManager)),
} as unknown as jest.Mocked<DataSource>;
const module: TestingModule = await Test.createTestingModule({
providers: [
UserProfileService,
{ provide: RlsQueryService, useValue: rlsQueryService },
{ provide: DataSource, useValue: dataSource },
],
}).compile();
service = module.get<UserProfileService>(UserProfileService);
});
describe('getProfile', () => {
it('should return user profile when user exists', async () => {
rlsQueryService.findOneWithRls.mockResolvedValue(mockUser as User);
const result = await service.getProfile(mockUserId);
expect(rlsQueryService.findOneWithRls).toHaveBeenCalledWith(User, {
where: { id: mockUserId },
select: ['id', 'name', 'email', 'avatarUrl', 'preferences'],
});
expect(result).toEqual({
name: 'John Doe',
email: 'test@example.com',
avatarUrl: 'https://example.com/avatar.png',
preferences: { locale: 'fr', timezone: 'Europe/Paris' },
});
});
it('should throw NotFoundException when user does not exist', async () => {
rlsQueryService.findOneWithRls.mockResolvedValue(null);
await expect(service.getProfile(mockUserId)).rejects.toThrow(NotFoundException);
});
it('should return profile with empty preferences when preferences is null', async () => {
const userWithoutPrefs = { ...mockUser, preferences: null };
rlsQueryService.findOneWithRls.mockResolvedValue(userWithoutPrefs as unknown as User);
const result = await service.getProfile(mockUserId);
expect(result.preferences).toEqual({});
});
});
describe('updateProfile', () => {
const updateDto: UpdateUserProfileDto = {
name: 'Jane Doe',
avatarUrl: 'https://new.example.com/avatar.jpg',
preferences: { locale: 'en' },
};
beforeEach(() => {
entityManager.findOne.mockResolvedValue(mockUser as User);
entityManager.save.mockImplementation(async (_, user) => user as User);
});
it('should update user profile and return updated data', async () => {
const result = await service.updateProfile(mockUserId, updateDto);
expect(entityManager.query).toHaveBeenCalledWith(
'SET LOCAL app.current_user_id = $1',
[mockUserId],
);
expect(entityManager.save).toHaveBeenCalled();
expect(result.name).toBe('Jane Doe');
expect(result.avatarUrl).toBe('https://new.example.com/avatar.jpg');
});
it('should throw NotFoundException when user does not exist', async () => {
entityManager.findOne.mockResolvedValue(null);
await expect(service.updateProfile(mockUserId, updateDto)).rejects.toThrow(NotFoundException);
});
it('should merge preferences instead of replacing them', async () => {
const existingUser = {
...mockUser,
preferences: {
locale: 'fr',
timezone: 'Europe/Paris',
notifications: { email: true },
},
};
entityManager.findOne.mockResolvedValue(existingUser as User);
entityManager.save.mockImplementation(async (_, user) => user as User);
const partialUpdate: UpdateUserProfileDto = { preferences: { locale: 'en' } };
await service.updateProfile(mockUserId, partialUpdate);
expect(entityManager.save).toHaveBeenCalledWith(
User,
expect.objectContaining({
preferences: expect.objectContaining({
locale: 'en',
timezone: 'Europe/Paris',
notifications: { email: true },
}),
}),
);
});
it('should only update provided fields', async () => {
const existingUser = { ...mockUser };
entityManager.findOne.mockResolvedValue(existingUser as User);
entityManager.save.mockImplementation(async (_, user) => user as User);
const partialUpdate: UpdateUserProfileDto = { name: 'Only Name Updated' };
await service.updateProfile(mockUserId, partialUpdate);
expect(entityManager.save).toHaveBeenCalledWith(
User,
expect.objectContaining({
name: 'Only Name Updated',
avatarUrl: mockUser.avatarUrl,
}),
);
});
it('should handle undefined fields without changing them', async () => {
const existingUser = { ...mockUser };
entityManager.findOne.mockResolvedValue(existingUser as User);
entityManager.save.mockImplementation(async (_, user) => user as User);
const emptyUpdate: UpdateUserProfileDto = {};
await service.updateProfile(mockUserId, emptyUpdate);
expect(entityManager.save).toHaveBeenCalledWith(
User,
expect.objectContaining({
name: mockUser.name,
avatarUrl: mockUser.avatarUrl,
preferences: mockUser.preferences,
}),
);
});
});
describe('INV-32-02: RLS enforcement', () => {
it('getProfile should use RLS-aware query service', async () => {
rlsQueryService.findOneWithRls.mockResolvedValue(mockUser as User);
await service.getProfile(mockUserId);
expect(rlsQueryService.findOneWithRls).toHaveBeenCalled();
});
it('updateProfile should set RLS context before query', async () => {
entityManager.findOne.mockResolvedValue(mockUser as User);
entityManager.save.mockImplementation(async (_, user) => user as User);
await service.updateProfile(mockUserId, { name: 'Test' });
const calls = entityManager.query.mock.calls;
expect(calls[0]).toEqual(['SET LOCAL app.current_user_id = $1', [mockUserId]]);
});
});
});
user-profile.e2e-spec.ts (Tests E2E)¶
/**
* UserProfile E2E Tests - PD-32
*/
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import * as request from 'supertest';
import { JwtService } from '@nestjs/jwt';
import { DataSource } from 'typeorm';
import { AppModule } from '../src/app.module';
import { User } from '../src/modules/auth/entities/user.entity';
describe('UserProfile (e2e) - PD-32', () => {
let app: INestApplication;
let jwtService: JwtService;
let dataSource: DataSource;
let testUser: User;
let authToken: string;
const TEST_EMAIL = `test-${Date.now()}@example.com`;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
jwtService = app.get(JwtService);
dataSource = app.get(DataSource);
const userRepo = dataSource.getRepository(User);
testUser = userRepo.create({
email: TEST_EMAIL,
srpSalt: Buffer.from('test-salt'),
passwordHash: Buffer.from('test-hash'),
name: 'Initial Name',
avatarUrl: 'https://initial.example.com/avatar.png',
preferences: { locale: 'fr' },
});
await userRepo.save(testUser);
authToken = jwtService.sign({
sub: testUser.id,
email: testUser.email,
});
});
afterAll(async () => {
if (dataSource && testUser) {
const userRepo = dataSource.getRepository(User);
await userRepo.delete({ id: testUser.id });
}
if (app) {
await app.close();
}
});
describe('GET /user/profile', () => {
describe('TC-NOM-01: Successful profile retrieval', () => {
it('should return user profile with correct fields', async () => {
const response = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).toHaveProperty('email', TEST_EMAIL);
expect(response.body).toHaveProperty('name');
expect(response.body).toHaveProperty('avatarUrl');
expect(response.body).toHaveProperty('preferences');
});
});
describe('TC-INV-32-03/04: Sensitive fields exclusion', () => {
it('should NOT expose passwordHash', async () => {
const response = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).not.toHaveProperty('passwordHash');
});
it('should NOT expose srpSalt', async () => {
const response = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).not.toHaveProperty('srpSalt');
});
it('should NOT expose validationToken', async () => {
const response = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).not.toHaveProperty('validationToken');
});
it('should NOT expose id', async () => {
const response = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).not.toHaveProperty('id');
});
it('should NOT expose plan', async () => {
const response = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).not.toHaveProperty('plan');
});
it('should NOT expose status', async () => {
const response = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).not.toHaveProperty('status');
});
});
describe('TC-ERR-01: Authentication required', () => {
it('should return 401 without Authorization header', async () => {
await request(app.getHttpServer()).get('/user/profile').expect(401);
});
it('should return 401 with invalid token', async () => {
await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
});
});
});
describe('PUT /user/profile', () => {
describe('TC-NOM-02: Successful profile update', () => {
it('should update name', async () => {
const response = await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Updated Name' })
.expect(200);
expect(response.body.name).toBe('Updated Name');
});
it('should update avatarUrl', async () => {
const response = await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ avatarUrl: 'https://new.example.com/avatar.jpg' })
.expect(200);
expect(response.body.avatarUrl).toBe('https://new.example.com/avatar.jpg');
});
it('should update preferences', async () => {
const response = await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ preferences: { locale: 'en', timezone: 'America/New_York' } })
.expect(200);
expect(response.body.preferences.locale).toBe('en');
expect(response.body.preferences.timezone).toBe('America/New_York');
});
});
describe('TC-INV-32-06: Protected fields rejection', () => {
it('should reject email in payload (400 Bad Request)', async () => {
await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Test', email: 'hacker@evil.com' })
.expect(400);
});
it('should reject plan in payload (400 Bad Request)', async () => {
await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Test', plan: 'business' })
.expect(400);
});
it('should reject id in payload (400 Bad Request)', async () => {
await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Test', id: '550e8400-e29b-41d4-a716-446655440000' })
.expect(400);
});
it('should reject status in payload (400 Bad Request)', async () => {
await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Test', status: 'ACTIVE' })
.expect(400);
});
it('should reject passwordHash in payload (400 Bad Request)', async () => {
await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Test', passwordHash: 'some-hash' })
.expect(400);
});
});
describe('TC-ERR-02: Validation errors', () => {
it('should reject non-HTTPS avatarUrl', async () => {
await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ avatarUrl: 'http://insecure.com/avatar.png' })
.expect(400);
});
it('should reject invalid locale in preferences', async () => {
await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ preferences: { locale: 'invalid' } })
.expect(400);
});
it('should reject name exceeding 100 chars', async () => {
await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'a'.repeat(101) })
.expect(400);
});
});
describe('TC-ERR-01: Authentication required', () => {
it('should return 401 without Authorization header', async () => {
await request(app.getHttpServer())
.put('/user/profile')
.send({ name: 'Test' })
.expect(401);
});
});
});
describe('RLS Cross-access protection', () => {
let otherUser: User;
let otherUserToken: string;
beforeAll(async () => {
const userRepo = dataSource.getRepository(User);
otherUser = userRepo.create({
email: `other-${Date.now()}@example.com`,
srpSalt: Buffer.from('other-salt'),
passwordHash: Buffer.from('other-hash'),
name: 'Other User',
preferences: {},
});
await userRepo.save(otherUser);
otherUserToken = jwtService.sign({
sub: otherUser.id,
email: otherUser.email,
});
});
afterAll(async () => {
if (dataSource && otherUser) {
const userRepo = dataSource.getRepository(User);
await userRepo.delete({ id: otherUser.id });
}
});
it('TC-ERR-03: User A cannot see User B profile via their token', async () => {
const responseA = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const responseB = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${otherUserToken}`)
.expect(200);
expect(responseA.body.email).toBe(TEST_EMAIL);
expect(responseB.body.email).toBe(otherUser.email);
expect(responseA.body.email).not.toBe(responseB.body.email);
});
it('TC-ERR-04: User A update does not affect User B', async () => {
const uniqueName = `UniqueNameA-${Date.now()}`;
await request(app.getHttpServer())
.put('/user/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: uniqueName })
.expect(200);
const responseB = await request(app.getHttpServer())
.get('/user/profile')
.set('Authorization', `Bearer ${otherUserToken}`)
.expect(200);
expect(responseB.body.name).not.toBe(uniqueName);
});
});
});
ARTEFACT À PRODUIRE¶
➡️ PD-32-review-tests.md
FORMAT DE SORTIE¶
# PD-32 — Revue des Tests
## Résumé
| Critère | Statut |
|---------|--------|
| Couverture TC-* | X/Y |
| Qualité assertions | ✅/⚠️/❌ |
| Isolation | ✅/⚠️/❌ |
| Edge cases | ✅/⚠️/❌ |
**Verdict** : ...
## Matrice de couverture
| TC-ID | Implémenté | Fichier | Commentaire |
|-------|------------|---------|-------------|
| TC-NOM-01 | ✅/❌ | ... | ... |
## Points à améliorer
| ID | Description | Gravité |
|----|-------------|---------|
| T-01 | ... | MAJEUR/MINEUR |
## Tests manquants
- ...