notifRepo = $this->createMock(NotificationRepositoryInterface::class); $this->utRepo = $this->createMock(UserTenantRepositoryInterface::class); $this->service = new NotificationService($this->notifRepo, $this->utRepo); } public function testCreateForUserInsertsNotification(): void { $this->notifRepo->expects($this->once()) ->method('create') ->with($this->callback(function (array $data): bool { return $data['recipient_user_id'] === 5 && $data['tenant_id'] === 1 && $data['type'] === 'user.created' && $data['title'] === 'New user: Alice'; })) ->willReturn(42); $result = $this->service->createForUser(5, 1, 'user.created', 'New user: Alice', null, 'admin/users/edit/abc', []); $this->assertSame(42, $result); } public function testCreateForUserRejectsMissingFields(): void { $this->notifRepo->expects($this->never())->method('create'); $this->assertFalse($this->service->createForUser(0, 1, 'user.created', 'Test', null, null, [])); $this->assertFalse($this->service->createForUser(1, 1, '', 'Test', null, null, [])); $this->assertFalse($this->service->createForUser(1, 1, 'user.created', '', null, null, [])); } public function testUnreadCountReturnsCorrectCount(): void { $this->notifRepo->expects($this->any())->method('countUnreadByUser')->with(3, null)->willReturn(7); $this->assertSame(7, $this->service->unreadCount(3)); } public function testUnreadCountReturnsZeroForInvalidUser(): void { $this->notifRepo->expects($this->never())->method('countUnreadByUser'); $this->assertSame(0, $this->service->unreadCount(0)); } public function testMarkAsReadUpdatesState(): void { $this->notifRepo->expects($this->once()) ->method('markRead') ->with(10, 3, null) ->willReturn(true); $this->assertTrue($this->service->markAsRead(3, 10)); } public function testMarkAsReadRejectsInvalidIds(): void { $this->notifRepo->expects($this->never())->method('markRead'); $this->assertFalse($this->service->markAsRead(0, 10)); $this->assertFalse($this->service->markAsRead(3, 0)); } public function testMarkAllAsReadAffectsAllUserNotifications(): void { $this->notifRepo->expects($this->once()) ->method('markAllReadByUser') ->with(3, null) ->willReturn(5); $this->assertSame(5, $this->service->markAllAsRead(3)); } public function testDismissDeletesNotification(): void { $this->notifRepo->expects($this->once()) ->method('delete') ->with(10, 3, null) ->willReturn(true); $this->assertTrue($this->service->dismiss(3, 10)); } public function testPurgeOldReadRemovesExpiredOnly(): void { $this->notifRepo->expects($this->once()) ->method('purgeReadOlderThanDays') ->with(90) ->willReturn(12); $this->assertSame(12, $this->service->purgeOldRead(90)); } public function testDeleteAllForUserDelegates(): void { $this->notifRepo->expects($this->once()) ->method('deleteAllByUser') ->with(5) ->willReturn(3); $this->assertSame(3, $this->service->deleteAllForUser(5)); } public function testListForUserReturnsNotifications(): void { $expected = [ ['id' => 1, 'title' => 'Test', 'is_read' => 0], ['id' => 2, 'title' => 'Test 2', 'is_read' => 1], ]; $this->notifRepo->expects($this->any())->method('listByUser')->with(3, null, 50, 0)->willReturn($expected); $this->assertSame($expected, $this->service->listForUser(3)); } public function testListForUserPassesTenantScope(): void { $this->notifRepo->expects($this->once()) ->method('listByUser') ->with(3, 9, 25, 0) ->willReturn([]); $this->assertSame([], $this->service->listForUser(3, 9, 25)); } // --- createForTenantUsers --- public function testCreateForTenantUsersCreatesForAllUsersExcludingActor(): void { $this->utRepo->expects($this->once()) ->method('listActiveUserIdsByTenantId') ->with(1) ->willReturn([10, 20, 30]); $this->notifRepo->expects($this->exactly(2)) ->method('create') ->willReturn(1); $result = $this->service->createForTenantUsers(1, 'system', 'Hello', null, null, [], 20); $this->assertSame(2, $result); } public function testCreateForTenantUsersSkipsSuppressedDuplicates(): void { $this->utRepo->expects($this->once()) ->method('listActiveUserIdsByTenantId') ->with(1) ->willReturn([10, 20, 30]); $this->notifRepo->expects($this->exactly(3)) ->method('create') ->willReturnOnConsecutiveCalls(11, false, 13); $result = $this->service->createForTenantUsers(1, 'user.activated', 'Activated', null, null, ['user_id' => 99], null); $this->assertSame(2, $result); } public function testCreateForTenantUsersCreatesForAllWhenNoExclude(): void { $this->utRepo->expects($this->once()) ->method('listActiveUserIdsByTenantId') ->with(1) ->willReturn([10, 20]); $this->notifRepo->expects($this->exactly(2)) ->method('create') ->willReturn(1); $result = $this->service->createForTenantUsers(1, 'system', 'Hello', null, null, [], null); $this->assertSame(2, $result); } public function testCreateForTenantUsersRejectsInvalidInputs(): void { $this->utRepo->expects($this->never())->method('listActiveUserIdsByTenantId'); $this->notifRepo->expects($this->never())->method('create'); $this->assertSame(0, $this->service->createForTenantUsers(0, 'system', 'Hello', null, null, [], null)); $this->assertSame(0, $this->service->createForTenantUsers(1, '', 'Hello', null, null, [], null)); $this->assertSame(0, $this->service->createForTenantUsers(1, 'system', '', null, null, [], null)); } // --- createForTenantAdminUsers --- public function testCreateForTenantAdminUsersFiltersToAllowedUsers(): void { $this->utRepo->expects($this->once()) ->method('listActiveUserIdsByTenantId') ->with(1) ->willReturn([10, 20, 30, 40]); $this->notifRepo->expects($this->exactly(2)) ->method('create') ->willReturn(1); $allowedUserIds = array_flip([20, 30]); $result = $this->service->createForTenantAdminUsers(1, 'system', 'Hello', null, null, [], null, $allowedUserIds); $this->assertSame(2, $result); } public function testCreateForTenantAdminUsersExcludesActor(): void { $this->utRepo->expects($this->once()) ->method('listActiveUserIdsByTenantId') ->with(1) ->willReturn([10, 20, 30]); $this->notifRepo->expects($this->once()) ->method('create') ->willReturn(1); $allowedUserIds = array_flip([20, 30]); $result = $this->service->createForTenantAdminUsers(1, 'system', 'Hello', null, null, [], 20, $allowedUserIds); $this->assertSame(1, $result); } public function testCreateForTenantAdminUsersRejectsEmptyAllowedUsers(): void { $this->utRepo->expects($this->never())->method('listActiveUserIdsByTenantId'); $this->notifRepo->expects($this->never())->method('create'); $this->assertSame(0, $this->service->createForTenantAdminUsers(1, 'system', 'Hello', null, null, [], null, [])); } public function testCreateForTenantAdminUsersRejectsInvalidInputs(): void { $this->utRepo->expects($this->never())->method('listActiveUserIdsByTenantId'); $this->notifRepo->expects($this->never())->method('create'); $allowed = array_flip([10]); $this->assertSame(0, $this->service->createForTenantAdminUsers(0, 'system', 'Hello', null, null, [], null, $allowed)); $this->assertSame(0, $this->service->createForTenantAdminUsers(1, '', 'Hello', null, null, [], null, $allowed)); $this->assertSame(0, $this->service->createForTenantAdminUsers(1, 'system', '', null, null, [], null, $allowed)); } public function testCreateForUserAddsDedupeMetadataForCoreTypes(): void { $this->notifRepo->expects($this->once()) ->method('create') ->with($this->callback(function (array $data): bool { return $data['type'] === 'user.deleted' && !empty($data['dedupe_fingerprint']) && !empty($data['dedupe_bucket']); })) ->willReturn(55); $result = $this->service->createForUser(5, 1, 'user.deleted', 'User deleted: Bob', null, null, ['user_id' => 33]); $this->assertSame(55, $result); } public function testCreateForUserSkipsDedupeMetadataForNonCoreTypes(): void { $this->notifRepo->expects($this->once()) ->method('create') ->with($this->callback(function (array $data): bool { return !isset($data['dedupe_fingerprint']) || $data['dedupe_fingerprint'] === null; })) ->willReturn(56); $result = $this->service->createForUser(5, 1, 'system', 'System', null, null, ['user_id' => 33]); $this->assertSame(56, $result); } }