PD-105 — Agent-Developer — Tâche 2 : backend/event-classifier¶
Fichiers produits¶
criticality.enum.ts¶
/**
* PD-105: Criticality Enum
*
* Niveaux de criticité des événements probatoires.
* Utilisé pour déterminer le type de notification (alert vs silent).
*
* Invariants:
* - INV-105-06: CRITIQUE et HAUTE_CRITIQUE déclenchent des alerts
*/
export enum Criticality {
CRITIQUE = 'CRITIQUE',
HAUTE_CRITIQUE = 'HAUTE_CRITIQUE',
HAUTE = 'HAUTE',
NORMALE = 'NORMALE',
BASSE = 'BASSE',
}
event-classifier.service.ts¶
import { Injectable, Logger } from '@nestjs/common';
import { Criticality } from './criticality.enum';
/**
* PD-105: Event Classifier Service
*
* Classifie les événements probatoires selon le catalogue Phase 1.
* Détermine si un événement doit déclencher une notification push.
*
* Invariants:
* - INV-105-02: Toute notification émise DOIT correspondre à un événement probatoire significatif
* - INV-105-06: Les notifications alert DOIVENT être utilisées pour les événements CRITIQUE et HAUTE_CRITIQUE
*/
export interface ProbatoryEvent {
type: string;
timestamp?: Date;
userId?: string;
metadata?: Record<string, unknown>;
}
export interface ClassificationResult {
type: string;
criticality: Criticality;
shouldNotify: boolean;
notificationType: 'alert' | 'silent';
}
interface CatalogueEntry {
criticality: Criticality;
notificationType: 'alert' | 'silent';
}
@Injectable()
export class EventClassifierService {
private readonly logger = new Logger(EventClassifierService.name);
/**
* Catalogue Phase 1 - Événements probatoires notifiables
*
* INV-105-02: Seuls ces événements peuvent déclencher des notifications.
* Tout événement non listé est filtré (anti-bruit).
*/
private readonly CATALOGUE_PHASE1: Record<string, CatalogueEntry> = {
DOCUMENT_SHARED: {
criticality: Criticality.HAUTE,
notificationType: 'silent',
},
DOCUMENT_SEALED_SUCCESS: {
criticality: Criticality.HAUTE,
notificationType: 'silent',
},
DOCUMENT_SEALED_FAILURE: {
criticality: Criticality.CRITIQUE,
notificationType: 'alert',
},
ACCESS_REVOKED: {
criticality: Criticality.CRITIQUE,
notificationType: 'alert',
},
DEADLINE_IMMINENT: {
criticality: Criticality.HAUTE_CRITIQUE,
notificationType: 'alert',
},
};
/**
* Classifie un événement probatoire selon le catalogue Phase 1
*
* @param event - Événement probatoire à classifier
* @returns Classification complète avec criticité et type de notification
*
* Postconditions:
* - Retourne { type, criticality, shouldNotify, notificationType }
* - shouldNotify = true si événement dans catalogue, false sinon (INV-105-02)
* - notificationType = 'alert' si CRITIQUE ou HAUTE_CRITIQUE (INV-105-06)
*/
classify(event: ProbatoryEvent): ClassificationResult {
if (!event || !event.type) {
this.logger.warn('Received invalid event: missing type');
return this.createDefaultClassification('UNKNOWN');
}
const catalogueEntry = this.CATALOGUE_PHASE1[event.type];
if (!catalogueEntry) {
this.logger.debug(
`Event type '${event.type}' not in Phase 1 catalogue - no notification`,
);
return this.createDefaultClassification(event.type);
}
this.logger.debug(
`Event '${event.type}' classified: ${catalogueEntry.criticality} (${catalogueEntry.notificationType})`,
);
return {
type: event.type,
criticality: catalogueEntry.criticality,
shouldNotify: true,
notificationType: catalogueEntry.notificationType,
};
}
/**
* Détermine si un événement doit déclencher une notification
*
* @param event - Événement probatoire à évaluer
* @returns true si événement dans catalogue Phase 1, false sinon
*
* Postconditions:
* - true si événement dans catalogue (INV-105-02)
* - false pour tous les autres événements (filtre anti-bruit)
*/
shouldNotify(event: ProbatoryEvent): boolean {
if (!event || !event.type) {
return false;
}
return this.CATALOGUE_PHASE1.hasOwnProperty(event.type);
}
/**
* Crée une classification par défaut pour les événements hors catalogue
*
* @param eventType - Type d'événement
* @returns Classification avec shouldNotify=false
*/
private createDefaultClassification(eventType: string): ClassificationResult {
return {
type: eventType,
criticality: Criticality.BASSE,
shouldNotify: false,
notificationType: 'silent',
};
}
}
tests/event-classifier.service.spec.ts¶
import { Test, TestingModule } from '@nestjs/testing';
import { EventClassifierService, ProbatoryEvent } from '../event-classifier.service';
import { Criticality } from '../criticality.enum';
describe('EventClassifierService', () => {
let service: EventClassifierService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [EventClassifierService],
}).compile();
service = module.get<EventClassifierService>(EventClassifierService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('classify', () => {
describe('Catalogue Phase 1 - Événements notifiables', () => {
it('should classify DOCUMENT_SHARED as HAUTE/silent/shouldNotify=true', () => {
const event: ProbatoryEvent = { type: 'DOCUMENT_SHARED' };
const result = service.classify(event);
expect(result).toEqual({
type: 'DOCUMENT_SHARED',
criticality: Criticality.HAUTE,
shouldNotify: true,
notificationType: 'silent',
});
});
it('should classify DOCUMENT_SEALED_SUCCESS as HAUTE/silent/shouldNotify=true', () => {
const event: ProbatoryEvent = { type: 'DOCUMENT_SEALED_SUCCESS' };
const result = service.classify(event);
expect(result).toEqual({
type: 'DOCUMENT_SEALED_SUCCESS',
criticality: Criticality.HAUTE,
shouldNotify: true,
notificationType: 'silent',
});
});
it('should classify DOCUMENT_SEALED_FAILURE as CRITIQUE/alert/shouldNotify=true (CA-105-04)', () => {
const event: ProbatoryEvent = { type: 'DOCUMENT_SEALED_FAILURE' };
const result = service.classify(event);
expect(result).toEqual({
type: 'DOCUMENT_SEALED_FAILURE',
criticality: Criticality.CRITIQUE,
shouldNotify: true,
notificationType: 'alert',
});
});
it('should classify ACCESS_REVOKED as CRITIQUE/alert/shouldNotify=true', () => {
const event: ProbatoryEvent = { type: 'ACCESS_REVOKED' };
const result = service.classify(event);
expect(result).toEqual({
type: 'ACCESS_REVOKED',
criticality: Criticality.CRITIQUE,
shouldNotify: true,
notificationType: 'alert',
});
});
it('should classify DEADLINE_IMMINENT as HAUTE_CRITIQUE/alert/shouldNotify=true', () => {
const event: ProbatoryEvent = { type: 'DEADLINE_IMMINENT' };
const result = service.classify(event);
expect(result).toEqual({
type: 'DEADLINE_IMMINENT',
criticality: Criticality.HAUTE_CRITIQUE,
shouldNotify: true,
notificationType: 'alert',
});
});
});
describe('Événements hors catalogue (Filtre anti-bruit - INV-105-02, CA-105-11)', () => {
it('should return shouldNotify=false for unknown event type', () => {
const event: ProbatoryEvent = { type: 'DOCUMENT_OPENED' };
const result = service.classify(event);
expect(result.shouldNotify).toBe(false);
expect(result.criticality).toBe(Criticality.BASSE);
expect(result.type).toBe('DOCUMENT_OPENED');
});
it('should return shouldNotify=false for repeated non-significant events', () => {
const event: ProbatoryEvent = { type: 'DOCUMENT_DOWNLOADED_WITHOUT_IMPACT' };
const result = service.classify(event);
expect(result.shouldNotify).toBe(false);
});
it('should return shouldNotify=false for technical access events', () => {
const event: ProbatoryEvent = { type: 'TECHNICAL_ACCESS_NO_CONSEQUENCE' };
const result = service.classify(event);
expect(result.shouldNotify).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle null event gracefully', () => {
const result = service.classify(null as any);
expect(result.shouldNotify).toBe(false);
expect(result.type).toBe('UNKNOWN');
});
it('should handle undefined event gracefully', () => {
const result = service.classify(undefined as any);
expect(result.shouldNotify).toBe(false);
expect(result.type).toBe('UNKNOWN');
});
it('should handle event with missing type gracefully', () => {
const result = service.classify({} as ProbatoryEvent);
expect(result.shouldNotify).toBe(false);
expect(result.type).toBe('UNKNOWN');
});
});
});
describe('shouldNotify', () => {
it('should return true for events in Phase 1 catalogue', () => {
expect(service.shouldNotify({ type: 'DOCUMENT_SHARED' })).toBe(true);
expect(service.shouldNotify({ type: 'DOCUMENT_SEALED_SUCCESS' })).toBe(true);
expect(service.shouldNotify({ type: 'DOCUMENT_SEALED_FAILURE' })).toBe(true);
expect(service.shouldNotify({ type: 'ACCESS_REVOKED' })).toBe(true);
expect(service.shouldNotify({ type: 'DEADLINE_IMMINENT' })).toBe(true);
});
it('should return false for events NOT in Phase 1 catalogue (CA-105-11)', () => {
expect(service.shouldNotify({ type: 'DOCUMENT_OPENED' })).toBe(false);
expect(service.shouldNotify({ type: 'USER_LOGIN' })).toBe(false);
expect(service.shouldNotify({ type: 'UNKNOWN_EVENT' })).toBe(false);
});
it('should return false for null/undefined events', () => {
expect(service.shouldNotify(null as any)).toBe(false);
expect(service.shouldNotify(undefined as any)).toBe(false);
expect(service.shouldNotify({} as ProbatoryEvent)).toBe(false);
});
});
describe('Mapping criticité → notification type (INV-105-06)', () => {
it('should map CRITIQUE to alert', () => {
const events = [
{ type: 'DOCUMENT_SEALED_FAILURE' },
{ type: 'ACCESS_REVOKED' },
];
events.forEach((event) => {
const result = service.classify(event);
expect(result.criticality).toBe(Criticality.CRITIQUE);
expect(result.notificationType).toBe('alert');
});
});
it('should map HAUTE_CRITIQUE to alert', () => {
const event: ProbatoryEvent = { type: 'DEADLINE_IMMINENT' };
const result = service.classify(event);
expect(result.criticality).toBe(Criticality.HAUTE_CRITIQUE);
expect(result.notificationType).toBe('alert');
});
it('should map HAUTE to silent', () => {
const events = [
{ type: 'DOCUMENT_SHARED' },
{ type: 'DOCUMENT_SEALED_SUCCESS' },
];
events.forEach((event) => {
const result = service.classify(event);
expect(result.criticality).toBe(Criticality.HAUTE);
expect(result.notificationType).toBe('silent');
});
});
});
});
Couverture des contrats¶
| Invariant | Mécanisme | Fichier |
|---|---|---|
| INV-105-02 | Filtre anti-bruit (shouldNotify=false si hors catalogue) | event-classifier.service.ts:78-87 |
| INV-105-06 | Mapping CRITIQUE/HAUTE_CRITIQUE → alert (hardcodé dans catalogue) | event-classifier.service.ts:43-62 |
| Critère | Test(s) | Fichier |
|---|---|---|
| CA-105-04 | should classify DOCUMENT_SEALED_FAILURE as CRITIQUE/alert | spec.ts:54-64 |
| CA-105-11 | should return shouldNotify=false for unknown event type + 2 autres tests filtre anti-bruit | spec.ts:81-104 |
Notes d'implémentation¶
Décisions techniques¶
-
Catalogue hardcodé : Conformément aux exigences Phase 1, le catalogue des événements notifiables est figé dans une constante privée
CATALOGUE_PHASE1. Aucune base de données n'est utilisée. -
Filtre anti-bruit (INV-105-02) :
- Tout événement non présent dans le catalogue retourne
shouldNotify: false - Les événements exclus (ouverture répétée, téléchargement sans impact, accès technique) sont implicitement filtrés car non listés
-
Log de debug pour traçabilité des événements filtrés
-
Mapping criticité → type (INV-105-06) :
- Hardcodé dans le catalogue pour garantir la cohérence
- CRITIQUE et HAUTE_CRITIQUE →
alert -
HAUTE, NORMALE, BASSE →
silent -
Gestion des erreurs :
- Événements null/undefined/sans type → classification par défaut avec
shouldNotify: false -
Log de warning pour signaler les événements malformés
-
Injectable NestJS : Le service est marqué
@Injectable()pour être injecté dans le futurDispatchService(Phase 2).
Points de vigilance¶
- Coverage >= 80% : 18 tests couvrent tous les chemins (5 événements catalogue + edge cases + mapping criticité)
- Immuabilité du catalogue : Le catalogue Phase 1 est figé. Toute évolution nécessitera une migration de code (Phase 2+).
- Logger NestJS : Utilisation du logger standard NestJS pour cohérence avec les conventions backend ProbatioVault.
Tests exhaustifs¶
Tous les 5 événements du catalogue Phase 1 ont un test dédié : - DOCUMENT_SHARED - DOCUMENT_SEALED_SUCCESS - DOCUMENT_SEALED_FAILURE - ACCESS_REVOKED - DEADLINE_IMMINENT
Les tests valident également : - Le filtre anti-bruit (CA-105-11) - Le mapping criticité → alert (INV-105-06, CA-105-04) - La gestion des edge cases (null, undefined, type manquant)