Aller au contenu

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

  1. 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.

  2. Filtre anti-bruit (INV-105-02) :

  3. Tout événement non présent dans le catalogue retourne shouldNotify: false
  4. 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
  5. Log de debug pour traçabilité des événements filtrés

  6. Mapping criticité → type (INV-105-06) :

  7. Hardcodé dans le catalogue pour garantir la cohérence
  8. CRITIQUE et HAUTE_CRITIQUE → alert
  9. HAUTE, NORMALE, BASSE → silent

  10. Gestion des erreurs :

  11. Événements null/undefined/sans type → classification par défaut avec shouldNotify: false
  12. Log de warning pour signaler les événements malformés

  13. Injectable NestJS : Le service est marqué @Injectable() pour être injecté dans le futur DispatchService (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)