fix(notifications): use createFromMessage directly for CLI-safe scheduler notifications

createForTenantAdminUsersFromMessage relies on listActiveUserIdsByTenantId
which returns empty results in CLI context (scheduler). Instead, iterate
admin users directly and call createFromMessage per user per tenant.
Verified end-to-end: notification created, dedup blocks duplicates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:38:47 +02:00
parent 34892aff6f
commit fe88e748a3
2 changed files with 52 additions and 45 deletions

View File

@@ -26,7 +26,7 @@ class SchedulerJobFailedNotificationListenerTest extends TestCase
$this->listener = new SchedulerJobFailedNotificationListener($this->notifService, $this->userRepo, $this->utRepo);
}
public function testHandleCreatesNotificationsForAdminsAcrossTenants(): void
public function testHandleCreatesNotificationsPerAdminPerTenant(): void
{
$this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')
->with([PermissionService::JOBS_MANAGE])
@@ -39,31 +39,53 @@ class SchedulerJobFailedNotificationListenerTest extends TestCase
[30, [2, 3]],
]);
$this->notifService->expects($this->exactly(3))
->method('createForTenantAdminUsersFromMessage')
->willReturnCallback(function (int $tenantId, NotificationMessage $message, ?int $excludeUserId, array $adminSet): int {
$this->assertContains($tenantId, [1, 2, 3]);
$calls = [];
$this->notifService->expects($this->exactly(4))
->method('createFromMessage')
->willReturnCallback(function (int $userId, ?int $tenantId, NotificationMessage $message) use (&$calls): int {
$calls[] = ['user' => $userId, 'tenant' => $tenantId];
$this->assertSame('scheduler.job_failed', $message->type());
$this->assertStringContainsString('System audit purge', $message->title());
$this->assertStringContainsString('Test Job', $message->title());
$this->assertSame('admin/scheduled-jobs', $message->link());
$this->assertNull($excludeUserId);
$this->assertArrayHasKey(20, $adminSet);
$this->assertArrayHasKey(30, $adminSet);
$this->assertSame('Scheduled job failed: %s', $message->titleKey());
$data = $message->data();
$this->assertSame('job:system_audit_purge', $data['dedupe_target']);
$this->assertSame('job_not_registered', $data['error_code']);
$this->assertSame('job:test_fail_job', $data['dedupe_target']);
return 1;
});
$this->listener->handle('scheduler.job_failed', [
'job_id' => 5,
'job_key' => 'system_audit_purge',
'label' => 'System audit purge',
'job_key' => 'test_fail_job',
'label' => 'Test Job',
'error_code' => 'job_not_registered',
'error_message' => null,
'trigger_type' => 'scheduler',
]);
// User 20: tenant 1, tenant 2. User 30: tenant 2, tenant 3. = 4 calls total.
$this->assertCount(4, $calls);
$this->assertSame(['user' => 20, 'tenant' => 1], $calls[0]);
$this->assertSame(['user' => 20, 'tenant' => 2], $calls[1]);
$this->assertSame(['user' => 30, 'tenant' => 2], $calls[2]);
$this->assertSame(['user' => 30, 'tenant' => 3], $calls[3]);
}
public function testHandleCreatesGlobalNotificationWhenNoTenants(): void
{
$this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([10]);
$this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->willReturn([]);
$this->notifService->expects($this->once())
->method('createFromMessage')
->with(10, null, $this->anything())
->willReturn(1);
$this->listener->handle('scheduler.job_failed', [
'job_id' => 1,
'job_key' => 'some_job',
'label' => 'Some Job',
'error_code' => 'unexpected_error',
]);
}
public function testHandleUsesJobKeyAsLabelWhenLabelEmpty(): void
@@ -72,8 +94,8 @@ class SchedulerJobFailedNotificationListenerTest extends TestCase
$this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->willReturn([1]);
$this->notifService->expects($this->once())
->method('createForTenantAdminUsersFromMessage')
->willReturnCallback(function (int $tenantId, NotificationMessage $message): int {
->method('createFromMessage')
->willReturnCallback(function (int $userId, ?int $tenantId, NotificationMessage $message): int {
$this->assertStringContainsString('my_job_key', $message->title());
return 1;
});
@@ -88,7 +110,7 @@ class SchedulerJobFailedNotificationListenerTest extends TestCase
public function testHandleSkipsEmptyPayload(): void
{
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->notifService->expects($this->never())->method('createFromMessage');
$this->listener->handle('scheduler.job_failed', []);
$this->listener->handle('scheduler.job_failed', ['job_key' => '', 'job_id' => 0]);
@@ -98,7 +120,7 @@ class SchedulerJobFailedNotificationListenerTest extends TestCase
public function testHandleSkipsWhenNoAdmins(): void
{
$this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([]);
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->notifService->expects($this->never())->method('createFromMessage');
$this->listener->handle('scheduler.job_failed', [
'job_id' => 5,
@@ -114,8 +136,8 @@ class SchedulerJobFailedNotificationListenerTest extends TestCase
$this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->willReturn([1]);
$this->notifService->expects($this->once())
->method('createForTenantAdminUsersFromMessage')
->willReturnCallback(function (int $tenantId, NotificationMessage $message): int {
->method('createFromMessage')
->willReturnCallback(function (int $userId, ?int $tenantId, NotificationMessage $message): int {
$this->assertNull($message->body());
return 1;
});