mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
test(notifications): unit tests for worker and adapters
Covers the dedup short-circuit, per-channel dispatch routing, alert persistence, error tagging, and legacy single-recipient fallback in the Notifications worker, plus the Console adapter's permission shape and the Webhook adapter's HMAC-SHA256 signing contract, header layout, response handling, and unsigned-when-secret-missing behaviour. Worker dispatch helpers move from private to protected so a test spy can override them without monkey-patching. The Swoole runtime hook flag mutation is now guarded by class_exists so the action can run under bare PHPUnit (no Swoole extension on the test host). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
90ef2d7487
commit
6bde54675e
@@ -55,7 +55,9 @@ class Notifications extends Action
|
||||
|
||||
public function action(Message $message, Document $project, Registry $register, Database $dbForProject, Log $log): void
|
||||
{
|
||||
Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP);
|
||||
if (\class_exists(Runtime::class)) {
|
||||
Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP);
|
||||
}
|
||||
$payload = $message->getPayload();
|
||||
|
||||
if (empty($payload)) {
|
||||
@@ -118,7 +120,7 @@ class Notifications extends Action
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatch(string $channel, string $address, array $payload, Registry $register, Database $database, Log $log): void
|
||||
protected function dispatch(string $channel, string $address, array $payload, Registry $register, Database $database, Log $log): void
|
||||
{
|
||||
switch ($channel) {
|
||||
case NOTIFICATION_CHANNEL_EMAIL:
|
||||
@@ -135,7 +137,7 @@ class Notifications extends Action
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchEmail(string $address, array $payload, Registry $register, Log $log): void
|
||||
protected function dispatchEmail(string $address, array $payload, Registry $register, Log $log): void
|
||||
{
|
||||
$smtp = $payload['smtp'] ?? [];
|
||||
if (empty($smtp) && empty(System::getEnv('_APP_SMTP_HOST'))) {
|
||||
@@ -279,7 +281,7 @@ class Notifications extends Action
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchConsole(string $address, array $payload, Database $database): void
|
||||
protected function dispatchConsole(string $address, array $payload, Database $database): void
|
||||
{
|
||||
$project = $payload['project'] ?? null;
|
||||
$projectId = \is_array($project) ? ($project['$id'] ?? null) : null;
|
||||
@@ -313,7 +315,7 @@ class Notifications extends Action
|
||||
$adapter->send($consoleMessage);
|
||||
}
|
||||
|
||||
private function dispatchWebhook(string $address, array $payload): void
|
||||
protected function dispatchWebhook(string $address, array $payload): void
|
||||
{
|
||||
$body = [
|
||||
'subject' => $payload['subject'] ?? '',
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Messaging\Adapter;
|
||||
|
||||
use Appwrite\Messaging\Adapter\Console;
|
||||
use Appwrite\Messaging\Messages\Console as ConsoleMessage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Utopia\Cache\Adapter\None as NoCache;
|
||||
use Utopia\Cache\Cache;
|
||||
use Utopia\Database\Adapter\Memory;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
|
||||
class ConsoleTest extends TestCase
|
||||
{
|
||||
private Database $database;
|
||||
private Authorization $authorization;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->authorization = new Authorization();
|
||||
$this->authorization->addRole(Role::any()->toString());
|
||||
|
||||
$this->database = new Database(new Memory(), new Cache(new NoCache()));
|
||||
$this->database
|
||||
->setAuthorization($this->authorization)
|
||||
->setDatabase('alertsTests')
|
||||
->setNamespace('alerts_' . \uniqid());
|
||||
|
||||
$this->database->create();
|
||||
$this->database->createCollection('alerts', [], [], [Permission::create(Role::any()), Permission::read(Role::any())], false);
|
||||
$this->database->createAttribute('alerts', 'messageId', Database::VAR_STRING, 255, false);
|
||||
$this->database->createAttribute('alerts', 'type', Database::VAR_STRING, 64, false, 'info');
|
||||
$this->database->createAttribute('alerts', 'channel', Database::VAR_STRING, 64, true);
|
||||
$this->database->createAttribute('alerts', 'userId', Database::VAR_STRING, 255, false);
|
||||
$this->database->createAttribute('alerts', 'teamId', Database::VAR_STRING, 255, false);
|
||||
$this->database->createAttribute('alerts', 'projectId', Database::VAR_STRING, 255, false);
|
||||
$this->database->createAttribute('alerts', 'title', Database::VAR_STRING, 256, true);
|
||||
$this->database->createAttribute('alerts', 'body', Database::VAR_STRING, 16384, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->authorization->cleanRoles();
|
||||
$this->authorization->addRole(Role::any()->toString());
|
||||
}
|
||||
|
||||
public function testWritesAlertWithCorrectSchema(): void
|
||||
{
|
||||
$message = new ConsoleMessage(
|
||||
recipients: [['userId' => 'user-1']],
|
||||
title: 'Hello',
|
||||
body: 'World',
|
||||
type: 'info',
|
||||
messageId: ID::custom('msg-aaa'),
|
||||
projectId: 'project-1',
|
||||
);
|
||||
|
||||
$adapter = new Console($this->database);
|
||||
$result = $adapter->send($message);
|
||||
|
||||
$this->assertSame(1, $result['deliveredTo']);
|
||||
|
||||
$stored = $this->database->getDocument('alerts', 'msg-aaa');
|
||||
$this->assertFalse($stored->isEmpty());
|
||||
$this->assertSame('msg-aaa', $stored->getAttribute('messageId'));
|
||||
$this->assertSame('console', $stored->getAttribute('channel'));
|
||||
$this->assertSame('user-1', $stored->getAttribute('userId'));
|
||||
$this->assertSame('project-1', $stored->getAttribute('projectId'));
|
||||
$this->assertSame('Hello', $stored->getAttribute('title'));
|
||||
$this->assertSame('World', $stored->getAttribute('body'));
|
||||
$this->assertSame('info', $stored->getAttribute('type'));
|
||||
}
|
||||
|
||||
public function testUserPermissionsScopedToRecipient(): void
|
||||
{
|
||||
$message = new ConsoleMessage(
|
||||
recipients: [['userId' => 'user-2']],
|
||||
title: 'Title',
|
||||
body: 'Body',
|
||||
messageId: ID::custom('msg-perms-user'),
|
||||
);
|
||||
|
||||
(new Console($this->database))->send($message);
|
||||
|
||||
$stored = $this->database->getDocument('alerts', 'msg-perms-user');
|
||||
$permissions = $stored->getPermissions();
|
||||
|
||||
$this->assertContains(Permission::read(Role::user('user-2')), $permissions);
|
||||
$this->assertContains(Permission::update(Role::user('user-2')), $permissions);
|
||||
$this->assertContains(Permission::delete(Role::user('user-2')), $permissions);
|
||||
}
|
||||
|
||||
public function testTeamRecipientGrantsTeamReadAndOwnerWrite(): void
|
||||
{
|
||||
$message = new ConsoleMessage(
|
||||
recipients: [['teamId' => 'team-9']],
|
||||
title: 'Heads up',
|
||||
body: '...',
|
||||
messageId: ID::custom('msg-team'),
|
||||
);
|
||||
|
||||
(new Console($this->database))->send($message);
|
||||
|
||||
$stored = $this->database->getDocument('alerts', 'msg-team');
|
||||
$permissions = $stored->getPermissions();
|
||||
|
||||
$this->assertContains(Permission::read(Role::team('team-9')), $permissions);
|
||||
$this->assertContains(Permission::update(Role::team('team-9', 'owner')), $permissions);
|
||||
$this->assertContains(Permission::delete(Role::team('team-9', 'owner')), $permissions);
|
||||
}
|
||||
|
||||
public function testRejectsForeignMessageType(): void
|
||||
{
|
||||
$adapter = new Console($this->database);
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Invalid message type.');
|
||||
|
||||
// ConsoleMessage extends nothing — pass an unrelated Message implementation
|
||||
$adapter->send(new \Appwrite\Messaging\Messages\Webhook(urls: ['https://example.test'], payload: []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Messaging\Adapter;
|
||||
|
||||
use Appwrite\Messaging\Adapter\Webhook;
|
||||
use Appwrite\Messaging\Messages\Webhook as WebhookMessage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Test double that captures the curl request the adapter would issue and
|
||||
* returns a scripted response, so we exercise the real signing/header logic
|
||||
* without touching the network.
|
||||
*/
|
||||
class CapturingWebhook extends Webhook
|
||||
{
|
||||
/**
|
||||
* @var array<int, array{method: string, url: string, headers: array<int, string>, body: string, timeout: int}>
|
||||
*/
|
||||
public array $captured = [];
|
||||
|
||||
/** @var array{statusCode: int, response: string|null, error: string|null} */
|
||||
public array $response = ['statusCode' => 200, 'response' => 'OK', 'error' => null];
|
||||
|
||||
protected function dispatch(string $method, string $url, array $headers, string $body, int $timeout): array
|
||||
{
|
||||
$this->captured[] = [
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'headers' => $headers,
|
||||
'body' => $body,
|
||||
'timeout' => $timeout,
|
||||
];
|
||||
return $this->response;
|
||||
}
|
||||
}
|
||||
|
||||
class WebhookTest extends TestCase
|
||||
{
|
||||
public function testPostsExpectedBodyShape(): void
|
||||
{
|
||||
$adapter = new CapturingWebhook();
|
||||
$payload = [
|
||||
'subject' => 'Hello',
|
||||
'body' => 'World',
|
||||
'recipient' => 'ops@example.test',
|
||||
'metadata' => ['foo' => 'bar'],
|
||||
];
|
||||
|
||||
$message = new WebhookMessage(
|
||||
urls: ['https://hooks.example.test/notify'],
|
||||
payload: $payload,
|
||||
);
|
||||
|
||||
$result = $adapter->send($message);
|
||||
|
||||
$this->assertSame(1, $result['deliveredTo']);
|
||||
$this->assertCount(1, $adapter->captured);
|
||||
|
||||
$request = $adapter->captured[0];
|
||||
$this->assertSame('POST', $request['method']);
|
||||
$this->assertSame('https://hooks.example.test/notify', $request['url']);
|
||||
|
||||
$sent = \json_decode($request['body'], true);
|
||||
$this->assertSame($payload, $sent);
|
||||
|
||||
$headerLine = \implode("\n", $request['headers']);
|
||||
$this->assertStringContainsString('Content-Type: application/json', $headerLine);
|
||||
$this->assertStringContainsString('X-Appwrite-Webhook-Timestamp:', $headerLine);
|
||||
}
|
||||
|
||||
public function testSigningSecretProducesHmacSha256Signature(): void
|
||||
{
|
||||
$adapter = new CapturingWebhook();
|
||||
$payload = ['subject' => 'Signed', 'body' => 'B'];
|
||||
$secret = 'super-secret';
|
||||
|
||||
$message = new WebhookMessage(
|
||||
urls: ['https://hooks.example.test/signed'],
|
||||
payload: $payload,
|
||||
signingSecret: $secret,
|
||||
);
|
||||
|
||||
$adapter->send($message);
|
||||
|
||||
$headers = $adapter->captured[0]['headers'];
|
||||
$body = $adapter->captured[0]['body'];
|
||||
|
||||
$timestamp = null;
|
||||
$signature = null;
|
||||
foreach ($headers as $header) {
|
||||
if (\str_starts_with($header, 'X-Appwrite-Webhook-Timestamp: ')) {
|
||||
$timestamp = \substr($header, \strlen('X-Appwrite-Webhook-Timestamp: '));
|
||||
} elseif (\str_starts_with($header, 'X-Appwrite-Webhook-Signature: ')) {
|
||||
$signature = \substr($header, \strlen('X-Appwrite-Webhook-Signature: '));
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull($timestamp, 'timestamp header must be present');
|
||||
$this->assertNotNull($signature, 'signature header must be present when secret is set');
|
||||
$this->assertStringStartsWith('sha256=', $signature);
|
||||
|
||||
$expected = 'sha256=' . \hash_hmac('sha256', $timestamp . '.' . $body, $secret);
|
||||
$this->assertSame($expected, $signature);
|
||||
}
|
||||
|
||||
public function testNoSecretLeavesPayloadUnsigned(): void
|
||||
{
|
||||
$adapter = new CapturingWebhook();
|
||||
$message = new WebhookMessage(
|
||||
urls: ['https://hooks.example.test/unsigned'],
|
||||
payload: ['x' => 1],
|
||||
signingSecret: null,
|
||||
);
|
||||
|
||||
$adapter->send($message);
|
||||
|
||||
$headerLine = \implode("\n", $adapter->captured[0]['headers']);
|
||||
$this->assertStringNotContainsString('X-Appwrite-Webhook-Signature', $headerLine);
|
||||
}
|
||||
|
||||
public function testEmptySecretIsTreatedAsUnsigned(): void
|
||||
{
|
||||
$adapter = new CapturingWebhook();
|
||||
$message = new WebhookMessage(
|
||||
urls: ['https://hooks.example.test/empty-secret'],
|
||||
payload: ['x' => 1],
|
||||
signingSecret: '',
|
||||
);
|
||||
|
||||
$adapter->send($message);
|
||||
|
||||
$headerLine = \implode("\n", $adapter->captured[0]['headers']);
|
||||
$this->assertStringNotContainsString('X-Appwrite-Webhook-Signature', $headerLine);
|
||||
}
|
||||
|
||||
public function testTwoXxIsSuccess(): void
|
||||
{
|
||||
$adapter = new CapturingWebhook();
|
||||
$adapter->response = ['statusCode' => 204, 'response' => '', 'error' => null];
|
||||
$message = new WebhookMessage(urls: ['https://hooks.example.test/ok'], payload: []);
|
||||
|
||||
$result = $adapter->send($message);
|
||||
|
||||
$this->assertSame(1, $result['deliveredTo']);
|
||||
}
|
||||
|
||||
public function testNonTwoXxSurfacesError(): void
|
||||
{
|
||||
$adapter = new CapturingWebhook();
|
||||
$adapter->response = ['statusCode' => 503, 'response' => 'Server', 'error' => null];
|
||||
$message = new WebhookMessage(urls: ['https://hooks.example.test/fail'], payload: []);
|
||||
|
||||
$result = $adapter->send($message);
|
||||
|
||||
$this->assertSame(0, $result['deliveredTo']);
|
||||
$error = $result['results'][0]['error'] ?? null;
|
||||
$this->assertSame('HTTP 503', $error);
|
||||
}
|
||||
|
||||
public function testCurlErrorSurfacesAsResultError(): void
|
||||
{
|
||||
$adapter = new CapturingWebhook();
|
||||
$adapter->response = ['statusCode' => 0, 'response' => null, 'error' => 'connection refused'];
|
||||
$message = new WebhookMessage(urls: ['https://hooks.example.test/down'], payload: []);
|
||||
|
||||
$result = $adapter->send($message);
|
||||
|
||||
$this->assertSame(0, $result['deliveredTo']);
|
||||
$this->assertSame('connection refused', $result['results'][0]['error']);
|
||||
}
|
||||
|
||||
public function testCustomHeadersForwarded(): void
|
||||
{
|
||||
$adapter = new CapturingWebhook();
|
||||
$message = new WebhookMessage(
|
||||
urls: ['https://hooks.example.test/with-headers'],
|
||||
payload: [],
|
||||
headers: ['X-Custom' => 'value'],
|
||||
);
|
||||
|
||||
$adapter->send($message);
|
||||
|
||||
$headerLine = \implode("\n", $adapter->captured[0]['headers']);
|
||||
$this->assertStringContainsString('X-Custom: value', $headerLine);
|
||||
}
|
||||
|
||||
public function testRejectsForeignMessageType(): void
|
||||
{
|
||||
$adapter = new CapturingWebhook();
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Invalid message type.');
|
||||
|
||||
$adapter->send(new \Appwrite\Messaging\Messages\Console(recipients: [['userId' => 'u']], title: 't', body: 'b'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Platform\Workers;
|
||||
|
||||
use Appwrite\Platform\Workers\Notifications;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Utopia\Cache\Adapter\None as NoCache;
|
||||
use Utopia\Cache\Cache;
|
||||
use Utopia\Database\Adapter\Memory;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Logger\Log;
|
||||
use Utopia\Queue\Message;
|
||||
use Utopia\Registry\Registry;
|
||||
|
||||
require_once __DIR__ . '/../../../../app/init.php';
|
||||
|
||||
/**
|
||||
* Spy worker that records dispatch invocations instead of touching SMTP, the
|
||||
* console alerts table, or external HTTP. Lets the worker tests assert
|
||||
* routing, error handling, and alert persistence in isolation.
|
||||
*/
|
||||
class SpyNotifications extends Notifications
|
||||
{
|
||||
/** @var array<int, array{channel: string, address: string, payload: array<string, mixed>}> */
|
||||
public array $dispatched = [];
|
||||
|
||||
/** @var array<string, \Throwable> */
|
||||
public array $throwOn = [];
|
||||
|
||||
protected function dispatch(string $channel, string $address, array $payload, Registry $register, Database $database, Log $log): void
|
||||
{
|
||||
$this->dispatched[] = [
|
||||
'channel' => $channel,
|
||||
'address' => $address,
|
||||
'payload' => $payload,
|
||||
];
|
||||
|
||||
if (isset($this->throwOn[$channel])) {
|
||||
throw $this->throwOn[$channel];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsTest extends TestCase
|
||||
{
|
||||
private Database $database;
|
||||
private Authorization $authorization;
|
||||
private Registry $registry;
|
||||
private Document $project;
|
||||
private Log $log;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->authorization = new Authorization();
|
||||
$this->authorization->addRole(Role::any()->toString());
|
||||
|
||||
$this->database = new Database(new Memory(), new Cache(new NoCache()));
|
||||
$this->database
|
||||
->setAuthorization($this->authorization)
|
||||
->setDatabase('notifTests')
|
||||
->setNamespace('notif_' . \uniqid());
|
||||
|
||||
$this->database->create();
|
||||
$this->database->createCollection(
|
||||
'alerts',
|
||||
[],
|
||||
[],
|
||||
[Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())],
|
||||
false,
|
||||
);
|
||||
$this->database->createAttribute('alerts', 'messageId', Database::VAR_STRING, 255, false);
|
||||
$this->database->createAttribute('alerts', 'type', Database::VAR_STRING, 64, false, 'info');
|
||||
$this->database->createAttribute('alerts', 'channel', Database::VAR_STRING, 64, true);
|
||||
$this->database->createAttribute('alerts', 'userId', Database::VAR_STRING, 255, false);
|
||||
$this->database->createAttribute('alerts', 'teamId', Database::VAR_STRING, 255, false);
|
||||
$this->database->createAttribute('alerts', 'projectId', Database::VAR_STRING, 255, false);
|
||||
$this->database->createAttribute('alerts', 'title', Database::VAR_STRING, 256, true);
|
||||
$this->database->createAttribute('alerts', 'body', Database::VAR_STRING, 16384, true);
|
||||
|
||||
$this->registry = new Registry();
|
||||
$this->project = new Document(['$id' => 'project-x']);
|
||||
$this->log = new Log();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->authorization->cleanRoles();
|
||||
$this->authorization->addRole(Role::any()->toString());
|
||||
}
|
||||
|
||||
private function buildMessage(array $payload): Message
|
||||
{
|
||||
return new Message([
|
||||
'pid' => 'pid',
|
||||
'queue' => 'v1-notifications',
|
||||
'timestamp' => \time(),
|
||||
'payload' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function testDispatchesPerChannelToCorrectAdapter(): void
|
||||
{
|
||||
$worker = new SpyNotifications();
|
||||
|
||||
$payload = [
|
||||
'project' => ['$id' => 'project-x'],
|
||||
'recipients' => [
|
||||
['address' => 'user@example.test', 'channel' => NOTIFICATION_CHANNEL_EMAIL],
|
||||
['address' => 'user-1', 'channel' => NOTIFICATION_CHANNEL_CONSOLE],
|
||||
['address' => 'https://hooks.example.test/in', 'channel' => NOTIFICATION_CHANNEL_WEBHOOK],
|
||||
],
|
||||
'subject' => 'Hi',
|
||||
'body' => 'Body',
|
||||
'dedupKey' => 'event-1',
|
||||
];
|
||||
|
||||
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
|
||||
|
||||
$this->assertCount(3, $worker->dispatched);
|
||||
$channels = \array_map(static fn ($d) => $d['channel'], $worker->dispatched);
|
||||
$this->assertSame([NOTIFICATION_CHANNEL_EMAIL, NOTIFICATION_CHANNEL_CONSOLE, NOTIFICATION_CHANNEL_WEBHOOK], $channels);
|
||||
}
|
||||
|
||||
public function testPersistsOneAlertPerRecipientChannel(): void
|
||||
{
|
||||
$worker = new SpyNotifications();
|
||||
$payload = [
|
||||
'project' => ['$id' => 'project-x'],
|
||||
'recipients' => [
|
||||
['address' => 'user-1', 'channel' => NOTIFICATION_CHANNEL_CONSOLE],
|
||||
['address' => 'user-2', 'channel' => NOTIFICATION_CHANNEL_CONSOLE],
|
||||
],
|
||||
'subject' => 'Heads up',
|
||||
'body' => 'Read me',
|
||||
'dedupKey' => 'evt-multi',
|
||||
'permissions' => [Permission::read(Role::any())],
|
||||
];
|
||||
|
||||
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
|
||||
|
||||
$rows = $this->database->find('alerts');
|
||||
$this->assertCount(2, $rows);
|
||||
$userIds = \array_map(static fn (Document $row) => $row->getAttribute('userId'), $rows);
|
||||
\sort($userIds);
|
||||
$this->assertSame(['user-1', 'user-2'], $userIds);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->assertSame(\md5('evt-multi'), $row->getAttribute('messageId'));
|
||||
$this->assertSame('console', $row->getAttribute('channel'));
|
||||
$this->assertSame('project-x', $row->getAttribute('projectId'));
|
||||
$this->assertSame('Heads up', $row->getAttribute('title'));
|
||||
}
|
||||
}
|
||||
|
||||
public function testDedupHitShortCircuitsBeforeDispatch(): void
|
||||
{
|
||||
$worker = new SpyNotifications();
|
||||
$payload = [
|
||||
'project' => ['$id' => 'project-x'],
|
||||
'recipients' => [['address' => 'user-1', 'channel' => NOTIFICATION_CHANNEL_CONSOLE]],
|
||||
'subject' => 'Sub',
|
||||
'body' => 'B',
|
||||
'dedupKey' => 'dup-key',
|
||||
];
|
||||
|
||||
// First run delivers and persists.
|
||||
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
|
||||
$this->assertCount(1, $worker->dispatched);
|
||||
|
||||
// Manually insert a row with the dedup messageId so alreadyDelivered() returns true.
|
||||
$messageId = \md5('dup-key');
|
||||
$this->database->createDocument('alerts', new Document([
|
||||
'$id' => $messageId,
|
||||
'$permissions' => [Permission::read(Role::any())],
|
||||
'messageId' => $messageId,
|
||||
'channel' => 'console',
|
||||
'title' => 'x',
|
||||
'body' => 'y',
|
||||
]));
|
||||
|
||||
$worker->dispatched = [];
|
||||
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
|
||||
$this->assertCount(0, $worker->dispatched, 'second invocation must short-circuit on dedup hit');
|
||||
}
|
||||
|
||||
public function testMissingRecipientsAndAddressThrows(): void
|
||||
{
|
||||
$worker = new SpyNotifications();
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('No recipients in payload');
|
||||
|
||||
$worker->action(
|
||||
$this->buildMessage(['project' => ['$id' => 'project-x'], 'subject' => '', 'body' => '']),
|
||||
$this->project,
|
||||
$this->registry,
|
||||
$this->database,
|
||||
$this->log,
|
||||
);
|
||||
}
|
||||
|
||||
public function testFallbackToLegacyRecipient(): void
|
||||
{
|
||||
$worker = new SpyNotifications();
|
||||
$payload = [
|
||||
'project' => ['$id' => 'project-x'],
|
||||
'recipient' => 'legacy@example.test',
|
||||
'subject' => 'X',
|
||||
'body' => 'Y',
|
||||
];
|
||||
|
||||
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
|
||||
|
||||
$this->assertCount(1, $worker->dispatched);
|
||||
$this->assertSame('legacy@example.test', $worker->dispatched[0]['address']);
|
||||
$this->assertSame(NOTIFICATION_CHANNEL_EMAIL, $worker->dispatched[0]['channel']);
|
||||
}
|
||||
|
||||
public function testDispatchErrorTagsLogAndPropagates(): void
|
||||
{
|
||||
$worker = new SpyNotifications();
|
||||
$worker->throwOn[NOTIFICATION_CHANNEL_WEBHOOK] = new \RuntimeException('boom');
|
||||
|
||||
$payload = [
|
||||
'project' => ['$id' => 'project-x'],
|
||||
'recipients' => [['address' => 'https://h.example.test', 'channel' => NOTIFICATION_CHANNEL_WEBHOOK]],
|
||||
'subject' => 's',
|
||||
'body' => 'b',
|
||||
'dedupKey' => 'err-1',
|
||||
];
|
||||
|
||||
try {
|
||||
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
|
||||
$this->fail('expected exception to propagate');
|
||||
} catch (\Throwable $error) {
|
||||
$this->assertSame('boom', $error->getMessage());
|
||||
}
|
||||
|
||||
$tags = $this->log->getTags();
|
||||
$this->assertSame(NOTIFICATION_CHANNEL_WEBHOOK, $tags['channel'] ?? null);
|
||||
$this->assertSame('boom', $tags['error'] ?? null);
|
||||
|
||||
$rows = $this->database->find('alerts');
|
||||
$this->assertCount(0, $rows, 'failed dispatch must not persist alert');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user