diff --git a/docs/superpowers/plans/2026-06-15-task-notifications.md b/docs/superpowers/plans/2026-06-15-task-notifications.md new file mode 100644 index 0000000..5706a50 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-task-notifications.md @@ -0,0 +1,438 @@ +# Task Notifications Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Recréer un producteur de notifications en notifiant le nouvel assigné d'une tâche et les collaborateurs ajoutés, via un listener Doctrine couvrant tous les chemins d'écriture. + +**Architecture:** Un unique `TaskNotificationListener` Doctrine écoute `onFlush` (collecte les destinataires à partir des changesets d'assignation et des ajouts de collaborateurs) et `postFlush` (persiste les `Notification` puis re-flush). L'acteur courant est lu via `Security`; on ne se notifie jamais soi-même, et sans acteur authentifié aucune notification n'est créée. + +**Tech Stack:** PHP 8.4, Symfony 8, Doctrine ORM 3.6, PHPUnit (KernelTestCase). + +--- + +## Référence spec + +`docs/superpowers/specs/2026-06-15-task-notifications-design.md` + +## File Structure + +- **Create** `src/EventListener/TaskNotificationListener.php` — listener Doctrine, seul producteur de notifications de tâche. Responsabilité unique : traduire les changements d'assignation/collaboration en entités `Notification`. +- **Create** `tests/Functional/EventListener/TaskNotificationListenerTest.php` — tests fonctionnels (KernelTestCase) couvrant tous les cas de la spec. +- Aucune migration, aucun changement d'entité, aucun changement frontend. + +### Détails de plateforme vérifiés + +- Doctrine ORM 3.6 : le mapping d'une `PersistentCollection` s'obtient via `$collection->getMapping()->fieldName` (objet `AssociationMapping`, **pas** un tableau). +- `Task` non-nullables : `number` (int), `title` (string), `project` (relation). `assignee` est nullable, `collaborators` est une `Collection`. +- En test, on réutilise un `Project` existant (chargé par les fixtures) et on crée des `User` frais (isolation par `uniqid`). +- L'acteur courant : `Security::getUser()` lit le token storage. En test, on pose un token via `TokenStorageInterface::setToken()`. + +--- + +## Task 1: Listener + notifications d'assignation + +**Files:** +- Create: `src/EventListener/TaskNotificationListener.php` +- Test: `tests/Functional/EventListener/TaskNotificationListenerTest.php` + +- [ ] **Step 1: Écrire les tests d'assignation (échouent)** + +Créer `tests/Functional/EventListener/TaskNotificationListenerTest.php` : + +```php +em = $c->get(EntityManagerInterface::class); + $this->notifications = $c->get(NotificationRepository::class); + $this->tokenStorage = $c->get(TokenStorageInterface::class); + + $project = $this->em->getRepository(Project::class)->findOneBy([]); + self::assertNotNull($project, 'Les fixtures doivent fournir au moins un projet.'); + $this->project = $project; + + $this->actor = $this->makeUser('actor'); + $this->alice = $this->makeUser('alice'); + $this->bob = $this->makeUser('bob'); + $this->em->flush(); + } + + public function testAssignmentToOtherUserCreatesNotification(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $task->setAssignee($this->alice); + $this->em->persist($task); + $this->em->flush(); + + $rows = $this->notifications->findBy(['user' => $this->alice]); + self::assertCount(1, $rows); + self::assertSame('task_assigned', $rows[0]->getType()); + self::assertStringContainsString((string) $task->getTitle(), (string) $rows[0]->getMessage()); + } + + public function testSelfAssignmentCreatesNoNotification(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $task->setAssignee($this->actor); + $this->em->persist($task); + $this->em->flush(); + + self::assertCount(0, $this->notifications->findBy(['user' => $this->actor])); + } + + public function testReassignmentNotifiesOnlyNewAssignee(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $task->setAssignee($this->alice); + $this->em->persist($task); + $this->em->flush(); + + $task->setAssignee($this->bob); + $this->em->flush(); + + self::assertCount(1, $this->notifications->findBy(['user' => $this->alice])); + self::assertCount(1, $this->notifications->findBy(['user' => $this->bob])); + } + + public function testAssigneeSetToNullCreatesNoNotificationForNull(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $task->setAssignee($this->alice); + $this->em->persist($task); + $this->em->flush(); + + $task->setAssignee(null); + $this->em->flush(); + + // alice a reçu la 1re notif, mais le passage à null n'en crée aucune autre. + self::assertCount(1, $this->notifications->findBy(['user' => $this->alice])); + } + + public function testNoActorCreatesNoNotification(): void + { + $this->tokenStorage->setToken(null); + + $task = $this->makeTask(); + $task->setAssignee($this->alice); + $this->em->persist($task); + $this->em->flush(); + + self::assertCount(0, $this->notifications->findBy(['user' => $this->alice])); + } + + private function makeUser(string $prefix): User + { + $user = new User(); + $user->setUsername($prefix.'-'.uniqid()); + $user->setPassword('x'); + $user->setRoles(['ROLE_USER']); + $this->em->persist($user); + + return $user; + } + + private function makeTask(): Task + { + $task = new Task(); + $task->setNumber(random_int(100000, 999999)); + $task->setTitle('Tâche de test '.uniqid()); + $task->setProject($this->project); + + return $task; + } + + private function loginAs(User $user): void + { + $this->tokenStorage->setToken( + new UsernamePasswordToken($user, 'main', $user->getRoles()), + ); + } +} +``` + +- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent** + +Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php` +Expected: FAIL — aucune `Notification` créée (le listener n'existe pas encore), `assertCount(1, ...)` échoue. + +- [ ] **Step 3: Créer le listener** + +Créer `src/EventListener/TaskNotificationListener.php` : + +```php + */ + private array $pending = []; + + public function __construct(private readonly Security $security) + { + } + + public function onFlush(OnFlushEventArgs $args): void + { + $actor = $this->security->getUser(); + if (!$actor instanceof User) { + return; + } + + $uow = $args->getObjectManager()->getUnitOfWork(); + + // Assignation sur une tâche nouvellement créée. + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if (!$entity instanceof Task) { + continue; + } + $assignee = $entity->getAssignee(); + if ($assignee instanceof User && $assignee !== $actor) { + $this->pending[] = ['user' => $assignee, 'type' => 'task_assigned', 'task' => $entity]; + } + } + + // Changement d'assignation sur une tâche existante. + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if (!$entity instanceof Task) { + continue; + } + $changeSet = $uow->getEntityChangeSet($entity); + if (!isset($changeSet['assignee'])) { + continue; + } + $new = $changeSet['assignee'][1]; + if ($new instanceof User && $new !== $actor) { + $this->pending[] = ['user' => $new, 'type' => 'task_assigned', 'task' => $entity]; + } + } + } + + public function postFlush(PostFlushEventArgs $args): void + { + if ([] === $this->pending) { + return; + } + + $pending = $this->pending; + $this->pending = []; + + $em = $args->getObjectManager(); + foreach ($pending as $item) { + $em->persist($this->buildNotification($item['user'], $item['type'], $item['task'])); + } + $em->flush(); + } + + private function buildNotification(User $user, string $type, Task $task): Notification + { + [$title, $message] = $this->render($type, $task); + + $notification = new Notification(); + $notification->setUser($user); + $notification->setType($type); + $notification->setTitle($title); + $notification->setMessage($message); + $notification->setCreatedAt(new DateTimeImmutable()); + + return $notification; + } + + /** + * @return array{0: string, 1: string} + */ + private function render(string $type, Task $task): array + { + $projectName = $task->getProject()?->getName() ?? ''; + $suffix = '' !== $projectName ? sprintf(' — %s', $projectName) : ''; + $context = sprintf('« %s »%s', (string) $task->getTitle(), $suffix); + + return match ($type) { + 'task_assigned' => ['Nouvelle tâche assignée', $context], + 'task_collaborator_added' => ['Ajout à une tâche', $context], + default => ['Notification', $context], + }; + } +} +``` + +- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent** + +Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/EventListener/TaskNotificationListener.php tests/Functional/EventListener/TaskNotificationListenerTest.php +git commit -m "feat(notification) : notifier le nouvel assigné d'une tâche" +``` + +--- + +## Task 2: Notifications d'ajout de collaborateur + +**Files:** +- Modify: `src/EventListener/TaskNotificationListener.php` (méthode `onFlush`) +- Test: `tests/Functional/EventListener/TaskNotificationListenerTest.php` (ajout de tests) + +- [ ] **Step 1: Ajouter les tests collaborateurs (échouent)** + +Ajouter ces deux méthodes dans `TaskNotificationListenerTest` : + +```php + public function testAddingCollaboratorCreatesNotification(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $this->em->persist($task); + $this->em->flush(); + + $task->addCollaborator($this->alice); + $this->em->flush(); + + $rows = $this->notifications->findBy(['user' => $this->alice]); + self::assertCount(1, $rows); + self::assertSame('task_collaborator_added', $rows[0]->getType()); + } + + public function testAddingSelfAsCollaboratorCreatesNoNotification(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $this->em->persist($task); + $this->em->flush(); + + $task->addCollaborator($this->actor); + $this->em->flush(); + + self::assertCount(0, $this->notifications->findBy(['user' => $this->actor])); + } +``` + +- [ ] **Step 2: Lancer les nouveaux tests pour vérifier qu'ils échouent** + +Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php --filter Collaborator` +Expected: FAIL — `testAddingCollaboratorCreatesNotification` échoue (aucune notification créée). + +- [ ] **Step 3: Étendre `onFlush` pour gérer les collaborateurs** + +Dans `src/EventListener/TaskNotificationListener.php`, ajouter ce bloc à la fin de `onFlush()`, juste avant la fin de méthode (après la boucle `getScheduledEntityUpdates`) : + +```php + // Ajout de collaborateur(s) (tâche nouvelle ou existante). + foreach ($uow->getScheduledCollectionUpdates() as $collection) { + $owner = $collection->getOwner(); + if (!$owner instanceof Task) { + continue; + } + if ('collaborators' !== $collection->getMapping()->fieldName) { + continue; + } + foreach ($collection->getInsertDiff() as $user) { + if ($user instanceof User && $user !== $actor) { + $this->pending[] = ['user' => $user, 'type' => 'task_collaborator_added', 'task' => $owner]; + } + } + } +``` + +- [ ] **Step 4: Lancer toute la classe de tests pour vérifier qu'elle passe** + +Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/EventListener/TaskNotificationListener.php tests/Functional/EventListener/TaskNotificationListenerTest.php +git commit -m "feat(notification) : notifier les collaborateurs ajoutés à une tâche" +``` + +--- + +## Task 3: Vérification globale & style + +**Files:** aucun nouveau fichier. + +- [ ] **Step 1: Lancer la suite de tests complète** + +Run: `make test` +Expected: PASS (aucune régression). + +- [ ] **Step 2: Corriger le style PHP** + +Run: `make php-cs-fixer-allow-risky` +Expected: les nouveaux fichiers sont conformes (strict types, ordre des imports). + +- [ ] **Step 3: Commit si php-cs-fixer a modifié des fichiers** + +```bash +git add -A +git commit -m "style(notification) : php-cs-fixer sur le listener de notifications" +``` + +(Sauter cette étape si php-cs-fixer n'a rien changé.) + +--- + +## Self-review (auteur du plan) + +- **Couverture spec :** assignation (création + update) ✔ Task 1 ; collaborateur ajouté ✔ Task 2 ; auto-exclusion ✔ (tests self) ; pas d'acteur → rien ✔ ; réassignation A→B ✔ ; `assignee=null` ✔ ; contenu réutilisant l'entité existante ✔ ; aucun changement front ✔. +- **Placeholders :** aucun — tout le code (listener + tests) est complet. +- **Cohérence des types :** `pending` typé `list` ; types `task_assigned` / `task_collaborator_added` identiques entre listener et tests ; `getMapping()->fieldName` (ORM 3) ; `addCollaborator()` confirmé sur l'entité Task. diff --git a/docs/superpowers/specs/2026-06-15-task-notifications-design.md b/docs/superpowers/specs/2026-06-15-task-notifications-design.md new file mode 100644 index 0000000..0a0f9d4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-task-notifications-design.md @@ -0,0 +1,126 @@ +# Notifications sur événements de tâche — Design + +**Date :** 2026-06-15 +**Ticket lié :** (à créer) — recâblage du système de notifications + +## Contexte & problème + +Le système de notifications de Lesstime est aujourd'hui une **coquille vide** : toute la +plomberie consommatrice existe encore (entité `Notification`, `NotificationProvider`, +`NotificationRepository`, `NotificationUnreadCountController`, `MarkAllReadController`, +et côté front `NotificationBell.vue` + `useNotifications.ts` + `services/notifications.ts` +qui poll toutes les 2 min), **mais plus aucun producteur ne crée de notification**. + +Cause : le seul producteur était `NotificationService`, déclenché par les `ClientTicket` +du portail client. Le commit `2a0b202` (« suppression du portail client ») a retiré +`ClientTicket`, `NotificationService` et les processors associés, laissant la cloche +interroger `/notifications/unread-count` dans le vide. Le compteur reste donc à 0 et le +dropdown est toujours vide. + +> Le travail récent LST-52 (pagination du `NotificationProvider`) est correct mais portait +> sur une liste structurellement toujours vide. + +## Objectif + +Rebrancher la **création** de notifications sur des événements **réels** qui existent +encore dans l'app : les événements de **tâche**. + +## Périmètre (MVP) + +### Déclencheurs & destinataires + +| Événement | Détection (changeset Doctrine) | Destinataire | Type | +|-----------|-------------------------------|--------------|------| +| Tâche assignée (création **ou** modif où `assignee` passe à un nouvel user) | `assignee` : `old ≠ new` et `new ≠ null` | le nouvel assigné | `task_assigned` | +| Collaborateur ajouté | `insertDiff` sur la collection `collaborators` | chaque user ajouté | `task_collaborator_added` | + +Règles : +- **Auto-exclusion** : si le destinataire == l'acteur courant, aucune notification. +- Réassignation A→B : seul **B** est notifié (pas de notification « désassigné » — hors scope). +- `assignee` passe à `null` : aucune notification. +- Si plusieurs personnes deviennent destinataires dans un même flush, chacune reçoit + sa notification. + +### Contenu des notifications + +Réutilise l'entité `Notification` existante (`user`, `type`, `title`, `message`, +`isRead`, `createdAt`) — **aucune migration**. + +- `task_assigned` → titre « Nouvelle tâche assignée », message `«{titre tâche}» — {nom projet}`. +- `task_collaborator_added` → titre « Ajout à une tâche », message `«{titre tâche}» — {nom projet}`. + +### Décisions de comportement + +1. **Pas d'acteur authentifié → pas de notification.** Les deux chemins utilisateurs réels + (frontend JWT, MCP token) ont toujours un user authentifié. CLI / fixtures / cron de + récurrence n'ont pas d'acteur → aucune notification. Effet de bord positif : `make fixtures` + ne génère pas de notifications parasites. +2. **Pas de lien cliquable** vers la tâche dans cette itération (l'entité `Notification` + n'a pas de champ URL ; la cloche affiche titre + message + date relative). Extension + future possible, hors scope MVP. + +## Architecture + +**Approche retenue : listener Doctrine `onFlush` / `postFlush`** (un seul point de vérité +qui couvre tous les chemins d'écriture — frontend API Platform, MCP, et tout futur chemin — +puisque tous persistent via `EntityManager::flush()`). + +Approches écartées : +- *Décorateur de processor API Platform + hooks dans les tools MCP* : logique dupliquée sur + plusieurs endroits, risque d'oublier un chemin (c'est exactement ce type d'oubli qui a créé + le bug initial). +- *Événements de domaine + Symfony Messenger async* : surdimensionné pour 2 événements, + ajoute transport + worker (YAGNI). + +### Composant : `App\EventListener\TaskNotificationListener` + +Enregistré via `#[AsDoctrineListener]` sur les événements `onFlush` et `postFlush`. +Dépendances injectées : `Symfony\Bundle\SecurityBundle\Security` (acteur courant). + +**`onFlush(OnFlushEventArgs $args)`** — collecte (ne persiste rien encore) : +1. `$uow = $em->getUnitOfWork();` +2. Acteur : `$actor = $this->security->getUser();` → si `null`, **on sort** (aucune notif). +3. Assignations : + - `getScheduledEntityInsertions()` : pour chaque `Task` insérée avec `assignee !== null` + et `assignee !== actor` → file `(assignee, 'task_assigned', task)`. + - `getScheduledEntityUpdates()` : pour chaque `Task`, `getEntityChangeSet($task)` ; + si `isset($cs['assignee'])` avec `[$old, $new] = $cs['assignee']`, `$new !== null` + et `$new !== actor` → file `(new, 'task_assigned', task)`. +4. Collaborateurs : + - `getScheduledCollectionUpdates()` : pour chaque `PersistentCollection` dont l'owner est + une `Task` et le champ vaut `collaborators`, `getInsertDiff()` donne les users ajoutés ; + pour chacun `!== actor` → file `(user, 'task_collaborator_added', task)`. +5. Stocke la file dans une propriété privée du listener. + +**`postFlush(PostFlushEventArgs $args)`** — persiste : +1. Si la file est vide, retour immédiat. +2. Vide la file dans une variable locale puis **réinitialise la propriété** (anti-réentrance). +3. Pour chaque entrée, crée une `Notification` (user, type, title, message, createdAt), + `persist`. +4. `$em->flush()` une seconde fois. Pas de boucle infinie : les `Notification` ne sont pas + des `Task`, donc ce second flush ne reschedule aucune assignation/collaboration. + +## Tests (PHPUnit, `make test`) + +Cas couverts : +- Assignation d'une tâche à un user (par un autre acteur) → 1 notification `task_assigned` + pour cet user. +- Auto-assignation (acteur s'assigne la tâche) → **aucune** notification. +- Ajout d'un collaborateur → 1 notification `task_collaborator_added` pour cet user. +- Réassignation A→B → seul **B** reçoit une notification. +- `assignee` passé à `null` → aucune notification. +- Pas d'acteur authentifié (contexte CLI) → aucune notification. + +## Hors périmètre + +- Notifications de changement de statut, d'échéance proche, de désassignation. +- Lien cliquable / navigation vers la tâche depuis la notification. +- Préférences utilisateur (opt-in/opt-out par type), notifications e-mail. +- Modification du front (la cloche consomme déjà l'API et s'affichera dès que des + notifications existent). + +## Fichiers impactés + +- **Nouveau** : `src/EventListener/TaskNotificationListener.php` +- **Nouveau** : tests PHPUnit (`tests/EventListener/` ou emplacement équivalent au projet). +- **Aucune** migration, **aucun** changement d'entité, **aucun** changement front. diff --git a/src/EventListener/TaskNotificationListener.php b/src/EventListener/TaskNotificationListener.php new file mode 100644 index 0000000..1795184 --- /dev/null +++ b/src/EventListener/TaskNotificationListener.php @@ -0,0 +1,123 @@ + */ + private array $pending = []; + + public function __construct(private readonly Security $security) {} + + public function onFlush(OnFlushEventArgs $args): void + { + $actor = $this->security->getUser(); + if (!$actor instanceof User) { + return; + } + + $uow = $args->getObjectManager()->getUnitOfWork(); + + // Assignation sur une tâche nouvellement créée. + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if (!$entity instanceof Task) { + continue; + } + $assignee = $entity->getAssignee(); + if ($assignee instanceof User && $assignee !== $actor) { + $this->pending[] = ['user' => $assignee, 'type' => 'task_assigned', 'task' => $entity]; + } + } + + // Changement d'assignation sur une tâche existante. + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if (!$entity instanceof Task) { + continue; + } + $changeSet = $uow->getEntityChangeSet($entity); + if (!isset($changeSet['assignee'])) { + continue; + } + $new = $changeSet['assignee'][1]; + if ($new instanceof User && $new !== $actor) { + $this->pending[] = ['user' => $new, 'type' => 'task_assigned', 'task' => $entity]; + } + } + + // Ajout de collaborateur(s) (tâche nouvelle ou existante). + foreach ($uow->getScheduledCollectionUpdates() as $collection) { + $owner = $collection->getOwner(); + if (!$owner instanceof Task) { + continue; + } + if ('collaborators' !== $collection->getMapping()->fieldName) { + continue; + } + foreach ($collection->getInsertDiff() as $user) { + if ($user instanceof User && $user !== $actor) { + $this->pending[] = ['user' => $user, 'type' => 'task_collaborator_added', 'task' => $owner]; + } + } + } + } + + public function postFlush(PostFlushEventArgs $args): void + { + if ([] === $this->pending) { + return; + } + + $pending = $this->pending; + $this->pending = []; + + $em = $args->getObjectManager(); + foreach ($pending as $item) { + $em->persist($this->buildNotification($item['user'], $item['type'], $item['task'])); + } + $em->flush(); + } + + private function buildNotification(User $user, string $type, Task $task): Notification + { + [$title, $message] = $this->render($type, $task); + + $notification = new Notification(); + $notification->setUser($user); + $notification->setType($type); + $notification->setTitle($title); + $notification->setMessage($message); + $notification->setCreatedAt(new DateTimeImmutable()); + + return $notification; + } + + /** + * @return array{0: string, 1: string} + */ + private function render(string $type, Task $task): array + { + $projectName = $task->getProject()?->getName() ?? ''; + $suffix = '' !== $projectName ? sprintf(' — %s', $projectName) : ''; + $context = sprintf('« %s »%s', (string) $task->getTitle(), $suffix); + + return match ($type) { + 'task_assigned' => ['Nouvelle tâche assignée', $context], + 'task_collaborator_added' => ['Ajout à une tâche', $context], + default => ['Notification', $context], + }; + } +} diff --git a/tests/Functional/EventListener/TaskNotificationListenerTest.php b/tests/Functional/EventListener/TaskNotificationListenerTest.php new file mode 100644 index 0000000..158fd17 --- /dev/null +++ b/tests/Functional/EventListener/TaskNotificationListenerTest.php @@ -0,0 +1,175 @@ +em = $c->get(EntityManagerInterface::class); + $this->notifications = $c->get(NotificationRepository::class); + $this->tokenStorage = $c->get(TokenStorageInterface::class); + + $project = $this->em->getRepository(Project::class)->findOneBy([]); + self::assertNotNull($project, 'Les fixtures doivent fournir au moins un projet.'); + $this->project = $project; + + $this->actor = $this->makeUser('actor'); + $this->alice = $this->makeUser('alice'); + $this->bob = $this->makeUser('bob'); + $this->em->flush(); + } + + public function testAssignmentToOtherUserCreatesNotification(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $task->setAssignee($this->alice); + $this->em->persist($task); + $this->em->flush(); + + $rows = $this->notifications->findBy(['user' => $this->alice]); + self::assertCount(1, $rows); + self::assertSame('task_assigned', $rows[0]->getType()); + self::assertStringContainsString((string) $task->getTitle(), (string) $rows[0]->getMessage()); + } + + public function testSelfAssignmentCreatesNoNotification(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $task->setAssignee($this->actor); + $this->em->persist($task); + $this->em->flush(); + + self::assertCount(0, $this->notifications->findBy(['user' => $this->actor])); + } + + public function testReassignmentNotifiesOnlyNewAssignee(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $task->setAssignee($this->alice); + $this->em->persist($task); + $this->em->flush(); + + $task->setAssignee($this->bob); + $this->em->flush(); + + self::assertCount(1, $this->notifications->findBy(['user' => $this->alice])); + self::assertCount(1, $this->notifications->findBy(['user' => $this->bob])); + } + + public function testAssigneeSetToNullCreatesNoNotificationForNull(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $task->setAssignee($this->alice); + $this->em->persist($task); + $this->em->flush(); + + $task->setAssignee(null); + $this->em->flush(); + + // alice a reçu la 1re notif, mais le passage à null n'en crée aucune autre. + self::assertCount(1, $this->notifications->findBy(['user' => $this->alice])); + } + + public function testNoActorCreatesNoNotification(): void + { + $this->tokenStorage->setToken(null); + + $task = $this->makeTask(); + $task->setAssignee($this->alice); + $this->em->persist($task); + $this->em->flush(); + + self::assertCount(0, $this->notifications->findBy(['user' => $this->alice])); + } + + public function testAddingCollaboratorCreatesNotification(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $this->em->persist($task); + $this->em->flush(); + + $task->addCollaborator($this->alice); + $this->em->flush(); + + $rows = $this->notifications->findBy(['user' => $this->alice]); + self::assertCount(1, $rows); + self::assertSame('task_collaborator_added', $rows[0]->getType()); + } + + public function testAddingSelfAsCollaboratorCreatesNoNotification(): void + { + $this->loginAs($this->actor); + + $task = $this->makeTask(); + $this->em->persist($task); + $this->em->flush(); + + $task->addCollaborator($this->actor); + $this->em->flush(); + + self::assertCount(0, $this->notifications->findBy(['user' => $this->actor])); + } + + private function makeUser(string $prefix): User + { + $user = new User(); + $user->setUsername($prefix.'-'.uniqid()); + $user->setPassword('x'); + $user->setRoles(['ROLE_USER']); + $this->em->persist($user); + + return $user; + } + + private function makeTask(): Task + { + $task = new Task(); + $task->setNumber(random_int(100000, 999999)); + $task->setTitle('Tâche de test '.uniqid()); + $task->setProject($this->project); + + return $task; + } + + private function loginAs(User $user): void + { + $this->tokenStorage->setToken( + new UsernamePasswordToken($user, 'main', $user->getRoles()), + ); + } +}